@sentry/junior-dashboard 0.270.0 → 0.271.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 +2 -2
- package/dist/client.js +77 -77
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +89 -61
- package/src/client/components/AGENTS.md +30 -59
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +38 -7
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/AGENTS.md +12 -33
- package/src/client/conversations/ConversationPage.tsx +44 -8
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/pages/automations/AGENTS.md +6 -25
- package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +54 -62
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
- package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/queries.ts +4 -2
- package/src/client/review-desk/reviewItemFormat.ts +54 -0
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/system/AGENTS.md +0 -12
- package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type {
|
|
4
|
+
AutomationSummary,
|
|
5
|
+
ReviewItemPayload,
|
|
6
|
+
} from "@sentry/junior/api/schema";
|
|
7
|
+
|
|
8
|
+
import { InlineError } from "../components/InlineError";
|
|
9
|
+
import { SearchInput } from "../components/SearchInput";
|
|
10
|
+
import { Skeleton } from "../components/ui/skeleton";
|
|
11
|
+
import { ConversationSectionHeading } from "../conversations/ConversationSectionHeading";
|
|
12
|
+
import { automationPath, formatRelativeTime } from "../format";
|
|
13
|
+
import { pathWithSearch } from "../searchParams";
|
|
14
|
+
import { cn } from "../styles";
|
|
15
|
+
import { reviewItemPath } from "./reviewItemFormat";
|
|
16
|
+
|
|
17
|
+
/** A sidebar group key. The page owns which groups are collapsed. */
|
|
18
|
+
export type InboxGroupKey = "archived" | "blocked" | "fyi" | "needs-you";
|
|
19
|
+
|
|
20
|
+
type Dot = "accent" | "muted" | "warning" | "closed";
|
|
21
|
+
|
|
22
|
+
const dotClass: Record<Dot, string> = {
|
|
23
|
+
accent: "bg-dashboard-accent",
|
|
24
|
+
closed: "border border-dashboard-text-faint",
|
|
25
|
+
muted: "bg-dashboard-text-faint",
|
|
26
|
+
warning: "bg-amber-300",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const rowClass =
|
|
30
|
+
"block min-w-0 rounded-lg px-3 py-2 text-inherit no-underline transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus";
|
|
31
|
+
|
|
32
|
+
function InboxRow(props: {
|
|
33
|
+
dot: Dot;
|
|
34
|
+
meta: ReactNode;
|
|
35
|
+
selected?: "always" | "wide";
|
|
36
|
+
title: string;
|
|
37
|
+
to: string;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<li>
|
|
41
|
+
<Link
|
|
42
|
+
aria-current={props.selected ? "page" : undefined}
|
|
43
|
+
className={cn(
|
|
44
|
+
rowClass,
|
|
45
|
+
props.selected === "always" && "bg-dashboard-surface-active",
|
|
46
|
+
props.selected === "wide" && "md:bg-dashboard-surface-active",
|
|
47
|
+
)}
|
|
48
|
+
to={props.to}
|
|
49
|
+
>
|
|
50
|
+
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2">
|
|
51
|
+
<span
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
className={cn(
|
|
54
|
+
"mt-[0.4rem] size-2 shrink-0 rounded-full",
|
|
55
|
+
dotClass[props.dot],
|
|
56
|
+
)}
|
|
57
|
+
/>
|
|
58
|
+
<span
|
|
59
|
+
className={cn(
|
|
60
|
+
"truncate font-sans text-sm font-medium leading-snug",
|
|
61
|
+
props.dot === "closed"
|
|
62
|
+
? "text-dashboard-text-muted"
|
|
63
|
+
: "text-dashboard-text",
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{props.title}
|
|
67
|
+
</span>
|
|
68
|
+
<span className="col-start-2 mt-1 truncate font-sans text-xs leading-tight text-dashboard-text-muted">
|
|
69
|
+
{props.meta}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
</Link>
|
|
73
|
+
</li>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function InboxGroup(props: {
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
collapsed: boolean;
|
|
80
|
+
count: number;
|
|
81
|
+
first: boolean;
|
|
82
|
+
label: string;
|
|
83
|
+
onToggle(): void;
|
|
84
|
+
}) {
|
|
85
|
+
if (props.count === 0) return null;
|
|
86
|
+
return (
|
|
87
|
+
<section aria-label={props.label}>
|
|
88
|
+
<ConversationSectionHeading
|
|
89
|
+
className={cn("px-3 pb-1", props.first ? "pt-1" : "pt-3")}
|
|
90
|
+
collapsed={props.collapsed}
|
|
91
|
+
count={props.count}
|
|
92
|
+
label={props.label}
|
|
93
|
+
onToggle={props.onToggle}
|
|
94
|
+
/>
|
|
95
|
+
{props.collapsed ? null : (
|
|
96
|
+
<ul className="m-0 grid list-none gap-0.5 p-0">{props.children}</ul>
|
|
97
|
+
)}
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function InboxRowsLoading() {
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
aria-busy="true"
|
|
106
|
+
aria-live="polite"
|
|
107
|
+
className="grid gap-0.5"
|
|
108
|
+
role="status"
|
|
109
|
+
>
|
|
110
|
+
<span className="sr-only">Loading review items</span>
|
|
111
|
+
<Skeleton className="mb-1 ml-3 mt-1.5 h-2.5 w-16" />
|
|
112
|
+
{[0, 1, 2, 3, 4].map((row) => (
|
|
113
|
+
<div className="grid gap-1 rounded-lg px-3 py-2" key={row}>
|
|
114
|
+
<Skeleton className={cn("h-4", row % 2 ? "w-3/5" : "w-4/5")} />
|
|
115
|
+
<Skeleton className="h-3 w-2/5 opacity-70" />
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* List the inbox: open items that need the viewer, notices, blocked
|
|
124
|
+
* Automations, and archived items, each in a group that collapses.
|
|
125
|
+
*/
|
|
126
|
+
export function ReviewDeskSidebar(props: {
|
|
127
|
+
archived: ReviewItemPayload[];
|
|
128
|
+
blocked: AutomationSummary[];
|
|
129
|
+
collapsed: ReadonlySet<InboxGroupKey>;
|
|
130
|
+
/** Shown under the groups, such as an empty state or load more. */
|
|
131
|
+
footer?: ReactNode;
|
|
132
|
+
error: boolean;
|
|
133
|
+
fyi: ReviewItemPayload[];
|
|
134
|
+
loading: boolean;
|
|
135
|
+
needsYou: ReviewItemPayload[];
|
|
136
|
+
onSearchChange(value: string): void;
|
|
137
|
+
onToggle(key: InboxGroupKey): void;
|
|
138
|
+
search: string;
|
|
139
|
+
searchParams: URLSearchParams;
|
|
140
|
+
selectedId?: string;
|
|
141
|
+
/** False while small screens show the list instead of the selected item. */
|
|
142
|
+
selectedShown: boolean;
|
|
143
|
+
/** The title of the Conversation that added an item, when it is known. */
|
|
144
|
+
sourceTitle(item: ReviewItemPayload): string | undefined;
|
|
145
|
+
}) {
|
|
146
|
+
const groups = [
|
|
147
|
+
{ items: props.needsYou, key: "needs-you", label: "Needs you" },
|
|
148
|
+
{ items: props.fyi, key: "fyi", label: "FYI" },
|
|
149
|
+
] as const;
|
|
150
|
+
const shown = [
|
|
151
|
+
...groups.map((group) => group.items.length),
|
|
152
|
+
props.blocked.length,
|
|
153
|
+
];
|
|
154
|
+
const firstKey = (index: number) =>
|
|
155
|
+
shown.slice(0, index).every((count) => count === 0);
|
|
156
|
+
const itemRow = (item: ReviewItemPayload, dot: Dot) => {
|
|
157
|
+
const source = item.automationId ? "Automation" : props.sourceTitle(item);
|
|
158
|
+
const age = formatRelativeTime(
|
|
159
|
+
item.status === "open" ? item.createdAt : item.closedAt,
|
|
160
|
+
);
|
|
161
|
+
return (
|
|
162
|
+
<InboxRow
|
|
163
|
+
dot={dot}
|
|
164
|
+
key={item.id}
|
|
165
|
+
meta={source ? `${source} · ${age}` : age}
|
|
166
|
+
selected={
|
|
167
|
+
item.id !== props.selectedId
|
|
168
|
+
? undefined
|
|
169
|
+
: props.selectedShown
|
|
170
|
+
? "always"
|
|
171
|
+
: "wide"
|
|
172
|
+
}
|
|
173
|
+
title={item.title}
|
|
174
|
+
to={pathWithSearch(reviewItemPath(item.id), props.searchParams)}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<aside className="grid h-full min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden border-r border-dashboard-border bg-dashboard-surface-panel">
|
|
181
|
+
<div className="px-5 py-3">
|
|
182
|
+
<h2 className="m-0 font-display text-lg font-medium leading-tight text-dashboard-text">
|
|
183
|
+
Inbox
|
|
184
|
+
</h2>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="px-4 pb-2">
|
|
187
|
+
<SearchInput
|
|
188
|
+
label="Search review items"
|
|
189
|
+
onChange={props.onSearchChange}
|
|
190
|
+
placeholder="Search items…"
|
|
191
|
+
size="compact"
|
|
192
|
+
value={props.search}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
<nav
|
|
196
|
+
aria-label="Review items"
|
|
197
|
+
className="min-h-0 overflow-y-auto overscroll-contain px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
|
198
|
+
>
|
|
199
|
+
{props.error ? (
|
|
200
|
+
<InlineError className="px-3 py-2">
|
|
201
|
+
Could not load review items.
|
|
202
|
+
</InlineError>
|
|
203
|
+
) : props.loading ? (
|
|
204
|
+
<InboxRowsLoading />
|
|
205
|
+
) : (
|
|
206
|
+
<>
|
|
207
|
+
{groups.map((group, index) => (
|
|
208
|
+
<InboxGroup
|
|
209
|
+
collapsed={props.collapsed.has(group.key)}
|
|
210
|
+
count={group.items.length}
|
|
211
|
+
first={firstKey(index)}
|
|
212
|
+
key={group.key}
|
|
213
|
+
label={group.label}
|
|
214
|
+
onToggle={() => props.onToggle(group.key)}
|
|
215
|
+
>
|
|
216
|
+
{group.items.map((item) =>
|
|
217
|
+
itemRow(item, item.kind === "action" ? "accent" : "muted"),
|
|
218
|
+
)}
|
|
219
|
+
</InboxGroup>
|
|
220
|
+
))}
|
|
221
|
+
<InboxGroup
|
|
222
|
+
collapsed={props.collapsed.has("blocked")}
|
|
223
|
+
count={props.blocked.length}
|
|
224
|
+
first={firstKey(2)}
|
|
225
|
+
label="Blocked automations"
|
|
226
|
+
onToggle={() => props.onToggle("blocked")}
|
|
227
|
+
>
|
|
228
|
+
{props.blocked.map((automation) => (
|
|
229
|
+
<InboxRow
|
|
230
|
+
dot="warning"
|
|
231
|
+
key={automation.id}
|
|
232
|
+
meta={automation.statusReason ?? "Blocked"}
|
|
233
|
+
title={automation.title}
|
|
234
|
+
to={automationPath(automation.id)}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</InboxGroup>
|
|
238
|
+
<InboxGroup
|
|
239
|
+
collapsed={props.collapsed.has("archived")}
|
|
240
|
+
count={props.archived.length}
|
|
241
|
+
first={firstKey(3)}
|
|
242
|
+
label="Archived"
|
|
243
|
+
onToggle={() => props.onToggle("archived")}
|
|
244
|
+
>
|
|
245
|
+
{props.archived.map((item) => itemRow(item, "closed"))}
|
|
246
|
+
</InboxGroup>
|
|
247
|
+
{props.footer}
|
|
248
|
+
</>
|
|
249
|
+
)}
|
|
250
|
+
</nav>
|
|
251
|
+
</aside>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, type ReactNode } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ConversationReportEvent,
|
|
4
|
+
ReviewItemPayload,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
6
|
+
|
|
7
|
+
import { getDashboardAgentName } from "../agentName";
|
|
8
|
+
import { ConversationTranscriptView } from "../conversations/ConversationTranscript";
|
|
9
|
+
import { TranscriptLoading } from "../conversations/TranscriptLoading";
|
|
10
|
+
import { useConversationData } from "../conversations/queries";
|
|
11
|
+
import type { ConversationTranscript } from "../types";
|
|
12
|
+
|
|
13
|
+
/** Messages before the item that the excerpt shows. */
|
|
14
|
+
const BEFORE_COUNT = 3;
|
|
15
|
+
/** Messages after the item that the excerpt shows, newest last. */
|
|
16
|
+
const AFTER_COUNT = 8;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Split the messages of a Conversation at the time an item was added. Only
|
|
20
|
+
* messages show: the tool call that added the item would repeat its card.
|
|
21
|
+
*/
|
|
22
|
+
function conversationExcerpt(
|
|
23
|
+
events: readonly ConversationReportEvent[],
|
|
24
|
+
itemCreatedAt: string,
|
|
25
|
+
): { after: ConversationReportEvent[]; before: ConversationReportEvent[] } {
|
|
26
|
+
const askedAt = Date.parse(itemCreatedAt);
|
|
27
|
+
const messages = events.filter(
|
|
28
|
+
(event) => event.data.type === "message" && event.data.role !== "system",
|
|
29
|
+
);
|
|
30
|
+
return {
|
|
31
|
+
after: messages
|
|
32
|
+
.filter((event) => Date.parse(event.createdAt) > askedAt)
|
|
33
|
+
.slice(-AFTER_COUNT),
|
|
34
|
+
before: messages
|
|
35
|
+
.filter((event) => Date.parse(event.createdAt) <= askedAt)
|
|
36
|
+
.slice(-BEFORE_COUNT),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Excerpt(props: {
|
|
41
|
+
conversation: ConversationTranscript;
|
|
42
|
+
events: ConversationReportEvent[];
|
|
43
|
+
}) {
|
|
44
|
+
const conversation = useMemo(
|
|
45
|
+
() => ({ ...props.conversation, events: props.events }),
|
|
46
|
+
[props.conversation, props.events],
|
|
47
|
+
);
|
|
48
|
+
return <ConversationTranscriptView conversation={conversation} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Show the end of the Conversation that added an item, split where the agent
|
|
53
|
+
* asked. Returns `fallback` when the viewer cannot read its messages.
|
|
54
|
+
*/
|
|
55
|
+
export function ReviewItemContext(props: {
|
|
56
|
+
fallback: ReactNode;
|
|
57
|
+
item: ReviewItemPayload;
|
|
58
|
+
}) {
|
|
59
|
+
const detail = useConversationData(props.item.addedByConversationId);
|
|
60
|
+
const data = detail.data;
|
|
61
|
+
const events = data?.events;
|
|
62
|
+
const excerpt = useMemo(
|
|
63
|
+
() =>
|
|
64
|
+
events ? conversationExcerpt(events, props.item.createdAt) : undefined,
|
|
65
|
+
[events, props.item.createdAt],
|
|
66
|
+
);
|
|
67
|
+
// The first page holds the newest events. Load earlier pages until the
|
|
68
|
+
// page reaches the time the item was added, or history ends.
|
|
69
|
+
const loadPreviousPageRef = useRef(detail.loadPreviousPage);
|
|
70
|
+
loadPreviousPageRef.current = detail.loadPreviousPage;
|
|
71
|
+
const oldest = events?.[0]?.createdAt;
|
|
72
|
+
const loadEarlier =
|
|
73
|
+
oldest !== undefined &&
|
|
74
|
+
Date.parse(oldest) > Date.parse(props.item.createdAt) &&
|
|
75
|
+
detail.hasPreviousPage &&
|
|
76
|
+
!detail.isLoadingPreviousPage &&
|
|
77
|
+
!detail.historyError;
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (loadEarlier) loadPreviousPageRef.current();
|
|
80
|
+
}, [loadEarlier, oldest]);
|
|
81
|
+
|
|
82
|
+
if (detail.isPending) return <TranscriptLoading />;
|
|
83
|
+
if (
|
|
84
|
+
!data ||
|
|
85
|
+
!excerpt ||
|
|
86
|
+
data.eventHistory.status !== "available" ||
|
|
87
|
+
excerpt.before.length + excerpt.after.length === 0
|
|
88
|
+
) {
|
|
89
|
+
return props.fallback;
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
<div className="grid min-w-0 gap-2">
|
|
93
|
+
{excerpt.before.length > 0 ? (
|
|
94
|
+
<Excerpt conversation={data} events={excerpt.before} />
|
|
95
|
+
) : null}
|
|
96
|
+
{excerpt.after.length > 0 ? (
|
|
97
|
+
<>
|
|
98
|
+
<div className="flex items-center gap-3 py-1 font-sans text-xs font-medium text-dashboard-accent-text">
|
|
99
|
+
<span className="h-px flex-1 bg-dashboard-accent/30" />
|
|
100
|
+
New since {getDashboardAgentName()} asked
|
|
101
|
+
<span className="h-px flex-1 bg-dashboard-accent/30" />
|
|
102
|
+
</div>
|
|
103
|
+
<Excerpt conversation={data} events={excerpt.after} />
|
|
104
|
+
</>
|
|
105
|
+
) : null}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { DetailsPanel } from "../components/DetailsPanel";
|
|
6
|
+
import { StatusChip } from "../components/StatusChip";
|
|
7
|
+
import { DetailsCard } from "../conversations/DetailsList";
|
|
8
|
+
import { conversationPath } from "../conversations/conversationRoutes";
|
|
9
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
10
|
+
import { automationPath, formatRelativeTime } from "../format";
|
|
11
|
+
import { dashboardInlineLinkClass } from "../styles";
|
|
12
|
+
import { useLinkedConversationTitles } from "./queries";
|
|
13
|
+
import {
|
|
14
|
+
assigneeLabel,
|
|
15
|
+
reviewItemStatusLabel,
|
|
16
|
+
reviewItemSubjectHref,
|
|
17
|
+
} from "./reviewItemFormat";
|
|
18
|
+
|
|
19
|
+
function Fact(props: { children: ReactNode; label: string }) {
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<dt className="text-dashboard-text-muted">{props.label}</dt>
|
|
23
|
+
<dd className="m-0 min-w-0 break-words text-dashboard-text">
|
|
24
|
+
{props.children}
|
|
25
|
+
</dd>
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Show the facts of one Review item beside it: its text, assignees, where
|
|
32
|
+
* it came from, and the Conversations linked to it.
|
|
33
|
+
*/
|
|
34
|
+
export function ReviewItemDetails(props: {
|
|
35
|
+
item: ReviewItemPayload;
|
|
36
|
+
onClose(): void;
|
|
37
|
+
}) {
|
|
38
|
+
const { item } = props;
|
|
39
|
+
const subject = item.subject;
|
|
40
|
+
const subjectHref = subject?.url
|
|
41
|
+
? reviewItemSubjectHref(subject.url)
|
|
42
|
+
: undefined;
|
|
43
|
+
const [addedByTitle, ...linkedTitles] = useLinkedConversationTitles([
|
|
44
|
+
item.addedByConversationId,
|
|
45
|
+
...item.conversationIds,
|
|
46
|
+
]);
|
|
47
|
+
const assigned = item.assignees.length > 0 || item.assignedToEveryone;
|
|
48
|
+
return (
|
|
49
|
+
<DetailsPanel
|
|
50
|
+
label="Review item details"
|
|
51
|
+
onClose={props.onClose}
|
|
52
|
+
title={
|
|
53
|
+
<h2 className="m-0 text-base font-semibold text-dashboard-text">
|
|
54
|
+
Details
|
|
55
|
+
</h2>
|
|
56
|
+
}
|
|
57
|
+
>
|
|
58
|
+
<div className="grid min-w-0 gap-3 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
|
59
|
+
<div className="min-w-0 break-words px-1 pb-1 font-sans text-sm leading-relaxed text-dashboard-text-muted">
|
|
60
|
+
<DisplayText text={item.body} />
|
|
61
|
+
</div>
|
|
62
|
+
<DetailsCard title="Assignees">
|
|
63
|
+
{assigned ? (
|
|
64
|
+
<span className="text-sm text-dashboard-text">
|
|
65
|
+
{assigneeLabel(item)}
|
|
66
|
+
</span>
|
|
67
|
+
) : (
|
|
68
|
+
<StatusChip size="compact" tone="warning">
|
|
69
|
+
Unassigned
|
|
70
|
+
</StatusChip>
|
|
71
|
+
)}
|
|
72
|
+
</DetailsCard>
|
|
73
|
+
<DetailsCard>
|
|
74
|
+
<dl className="m-0 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5">
|
|
75
|
+
<Fact label="Status">{reviewItemStatusLabel(item)}</Fact>
|
|
76
|
+
<Fact label="Added">{formatRelativeTime(item.createdAt)}</Fact>
|
|
77
|
+
<Fact label="Added by">
|
|
78
|
+
<Link
|
|
79
|
+
className={dashboardInlineLinkClass}
|
|
80
|
+
title={item.addedByConversationId}
|
|
81
|
+
to={conversationPath(item.addedByConversationId, {
|
|
82
|
+
turnId: item.addedByTurnId,
|
|
83
|
+
})}
|
|
84
|
+
>
|
|
85
|
+
{addedByTitle ?? "Conversation"}
|
|
86
|
+
</Link>
|
|
87
|
+
{item.automationId ? (
|
|
88
|
+
<>
|
|
89
|
+
{" · "}
|
|
90
|
+
<Link
|
|
91
|
+
className={dashboardInlineLinkClass}
|
|
92
|
+
to={automationPath(item.automationId)}
|
|
93
|
+
>
|
|
94
|
+
Automation
|
|
95
|
+
</Link>
|
|
96
|
+
</>
|
|
97
|
+
) : null}
|
|
98
|
+
</Fact>
|
|
99
|
+
<Fact label="Visibility">
|
|
100
|
+
{item.visibility === "private" ? "Private" : "Public"}
|
|
101
|
+
</Fact>
|
|
102
|
+
</dl>
|
|
103
|
+
</DetailsCard>
|
|
104
|
+
{subject ? (
|
|
105
|
+
<DetailsCard title="Subject">
|
|
106
|
+
<span className="text-sm">
|
|
107
|
+
{subjectHref ? (
|
|
108
|
+
<a
|
|
109
|
+
className={dashboardInlineLinkClass}
|
|
110
|
+
href={subjectHref}
|
|
111
|
+
rel="noreferrer"
|
|
112
|
+
target="_blank"
|
|
113
|
+
>
|
|
114
|
+
{subject.title ?? subjectHref}
|
|
115
|
+
</a>
|
|
116
|
+
) : (
|
|
117
|
+
(subject.title ?? subject.id)
|
|
118
|
+
)}
|
|
119
|
+
</span>
|
|
120
|
+
</DetailsCard>
|
|
121
|
+
) : null}
|
|
122
|
+
{item.conversationIds.length > 0 ? (
|
|
123
|
+
<DetailsCard
|
|
124
|
+
count={item.conversationIds.length}
|
|
125
|
+
title="Linked conversations"
|
|
126
|
+
>
|
|
127
|
+
<ul className="m-0 grid list-none gap-1 p-0 text-sm">
|
|
128
|
+
{item.conversationIds.map((conversationId, index) => {
|
|
129
|
+
const title = linkedTitles[index];
|
|
130
|
+
return (
|
|
131
|
+
<li
|
|
132
|
+
className={
|
|
133
|
+
title ? "truncate" : "truncate font-mono text-xs"
|
|
134
|
+
}
|
|
135
|
+
key={conversationId}
|
|
136
|
+
>
|
|
137
|
+
<Link
|
|
138
|
+
className={dashboardInlineLinkClass}
|
|
139
|
+
title={conversationId}
|
|
140
|
+
to={conversationPath(conversationId)}
|
|
141
|
+
>
|
|
142
|
+
{title ?? conversationId}
|
|
143
|
+
</Link>
|
|
144
|
+
</li>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</ul>
|
|
148
|
+
</DetailsCard>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
</DetailsPanel>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ArrowRight, ChevronLeft, PanelRight } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { Link } from "react-router";
|
|
4
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
5
|
+
|
|
6
|
+
import { StatusChip } from "../components/StatusChip";
|
|
7
|
+
import { Button } from "../components/ui/button";
|
|
8
|
+
import { ChatLayout } from "../conversations/ChatLayout";
|
|
9
|
+
import { conversationPath } from "../conversations/conversationRoutes";
|
|
10
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
11
|
+
import { formatRelativeTime } from "../format";
|
|
12
|
+
import { ReviewItemActions } from "./ReviewItemActions";
|
|
13
|
+
import { ReviewItemContext } from "./ReviewItemContext";
|
|
14
|
+
import { reviewItemStatusLabel } from "./reviewItemFormat";
|
|
15
|
+
|
|
16
|
+
/** Space above the card when the view starts at its top. */
|
|
17
|
+
const CARD_TOP_MARGIN_PX = 24;
|
|
18
|
+
|
|
19
|
+
const textLinkClass =
|
|
20
|
+
"inline-flex items-center gap-1.5 font-sans text-sm font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Show one Review item where a person decides it: the end of the
|
|
24
|
+
* Conversation that added it, then its question and actions.
|
|
25
|
+
*/
|
|
26
|
+
export function ReviewItemView(props: {
|
|
27
|
+
backTo: string;
|
|
28
|
+
detailsOpen: boolean;
|
|
29
|
+
item: ReviewItemPayload;
|
|
30
|
+
next?: { title: string; to: string };
|
|
31
|
+
onDetailsClick(): void;
|
|
32
|
+
/** The title of the Conversation that added the item, when it is known. */
|
|
33
|
+
sourceTitle?: string;
|
|
34
|
+
}) {
|
|
35
|
+
const { item } = props;
|
|
36
|
+
const open = item.status === "open";
|
|
37
|
+
const articleRef = useRef<HTMLElement>(null);
|
|
38
|
+
const cardRef = useRef<HTMLElement>(null);
|
|
39
|
+
// Open at the decision: the messages above it are context. Show the end
|
|
40
|
+
// of the page, or the top of the card when the card is taller than the
|
|
41
|
+
// view. Messages load and grow after the first paint, so keep the place
|
|
42
|
+
// until the reader scrolls or uses the page.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const article = articleRef.current;
|
|
45
|
+
const card = cardRef.current;
|
|
46
|
+
const scroll = article?.closest<HTMLElement>("[data-chat-scroll]");
|
|
47
|
+
if (!article || !card || !scroll) return;
|
|
48
|
+
const observer = new ResizeObserver(() => {
|
|
49
|
+
const cardTop =
|
|
50
|
+
card.getBoundingClientRect().top -
|
|
51
|
+
scroll.getBoundingClientRect().top +
|
|
52
|
+
scroll.scrollTop;
|
|
53
|
+
scroll.scrollTop = Math.min(
|
|
54
|
+
scroll.scrollHeight - scroll.clientHeight,
|
|
55
|
+
cardTop - CARD_TOP_MARGIN_PX,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
const stop = () => observer.disconnect();
|
|
59
|
+
const events = ["keydown", "pointerdown", "touchstart", "wheel"] as const;
|
|
60
|
+
observer.observe(article);
|
|
61
|
+
for (const name of events) {
|
|
62
|
+
scroll.addEventListener(name, stop, { passive: true });
|
|
63
|
+
}
|
|
64
|
+
return () => {
|
|
65
|
+
stop();
|
|
66
|
+
for (const name of events) scroll.removeEventListener(name, stop);
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
const source = item.automationId ? "Automation" : props.sourceTitle;
|
|
70
|
+
// A notice has no question, so its text is what the card shows.
|
|
71
|
+
const bodyInCard = item.kind === "notice" || !item.request;
|
|
72
|
+
const conversationLink = (
|
|
73
|
+
<Link
|
|
74
|
+
className={textLinkClass}
|
|
75
|
+
to={conversationPath(item.addedByConversationId, {
|
|
76
|
+
turnId: item.addedByTurnId,
|
|
77
|
+
})}
|
|
78
|
+
>
|
|
79
|
+
Open conversation
|
|
80
|
+
<ArrowRight aria-hidden="true" size={14} />
|
|
81
|
+
</Link>
|
|
82
|
+
);
|
|
83
|
+
return (
|
|
84
|
+
<ChatLayout
|
|
85
|
+
header={
|
|
86
|
+
<header className="border-b border-dashboard-border bg-dashboard-bg">
|
|
87
|
+
<div className="flex min-w-0 items-start justify-between gap-4 px-4 py-3 md:px-7 md:py-5">
|
|
88
|
+
<div className="grid min-w-0 gap-1.5">
|
|
89
|
+
<Link
|
|
90
|
+
className="inline-flex items-center gap-1 font-sans text-xs text-dashboard-text-muted no-underline hover:text-dashboard-text md:hidden"
|
|
91
|
+
to={props.backTo}
|
|
92
|
+
>
|
|
93
|
+
<ChevronLeft aria-hidden="true" size={14} />
|
|
94
|
+
Inbox
|
|
95
|
+
</Link>
|
|
96
|
+
<h2 className="m-0 min-w-0 break-words font-display text-xl font-medium leading-tight tracking-tight">
|
|
97
|
+
{item.title}
|
|
98
|
+
</h2>
|
|
99
|
+
<p className="m-0 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-dashboard-text-muted">
|
|
100
|
+
<StatusChip size="compact" tone={open ? "accent" : "neutral"}>
|
|
101
|
+
{reviewItemStatusLabel(item)}
|
|
102
|
+
</StatusChip>
|
|
103
|
+
<span>
|
|
104
|
+
{source ? `${source} · ` : null}
|
|
105
|
+
{formatRelativeTime(item.createdAt)}
|
|
106
|
+
{item.visibility === "private" ? " · Private" : null}
|
|
107
|
+
</span>
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
<Button
|
|
111
|
+
aria-label="Review item details"
|
|
112
|
+
aria-pressed={props.detailsOpen}
|
|
113
|
+
className="shrink-0"
|
|
114
|
+
onClick={props.onDetailsClick}
|
|
115
|
+
>
|
|
116
|
+
<PanelRight aria-hidden="true" size={15} strokeWidth={2} />
|
|
117
|
+
Details
|
|
118
|
+
</Button>
|
|
119
|
+
</div>
|
|
120
|
+
</header>
|
|
121
|
+
}
|
|
122
|
+
scrollAriaLabel="Review item"
|
|
123
|
+
scrollClassName="px-4 pb-6 md:px-7"
|
|
124
|
+
scroll={
|
|
125
|
+
<article
|
|
126
|
+
aria-label={item.title}
|
|
127
|
+
ref={articleRef}
|
|
128
|
+
className="mx-auto grid w-full min-w-0 max-w-[52.5rem] gap-5 pt-5 md:pt-7"
|
|
129
|
+
>
|
|
130
|
+
<ReviewItemContext
|
|
131
|
+
fallback={
|
|
132
|
+
<div className="grid gap-3 font-sans text-sm leading-relaxed">
|
|
133
|
+
{bodyInCard ? null : <DisplayText text={item.body} />}
|
|
134
|
+
<div>{conversationLink}</div>
|
|
135
|
+
</div>
|
|
136
|
+
}
|
|
137
|
+
item={item}
|
|
138
|
+
/>
|
|
139
|
+
<section
|
|
140
|
+
aria-label={open ? "Answer" : "Outcome"}
|
|
141
|
+
ref={cardRef}
|
|
142
|
+
className="grid min-w-0 gap-4 rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-panel p-4 font-sans text-sm md:p-5"
|
|
143
|
+
>
|
|
144
|
+
{bodyInCard ? <DisplayText text={item.body} /> : null}
|
|
145
|
+
<ReviewItemActions item={item} />
|
|
146
|
+
</section>
|
|
147
|
+
{props.next ? (
|
|
148
|
+
<div className="flex justify-end">
|
|
149
|
+
<Link className={textLinkClass} replace to={props.next.to}>
|
|
150
|
+
Next: {props.next.title}
|
|
151
|
+
<ArrowRight aria-hidden="true" size={14} />
|
|
152
|
+
</Link>
|
|
153
|
+
</div>
|
|
154
|
+
) : null}
|
|
155
|
+
</article>
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -25,17 +25,19 @@ const COUNT_REFRESH_MS = 60_000;
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* List the open Review items that the viewer can see, one page at a time.
|
|
28
|
-
* A search also finds archived items, with the first
|
|
29
|
-
* the items of every loaded page, and `query` loads more.
|
|
28
|
+
* A search or `includeArchived` also finds archived items, with the first
|
|
29
|
+
* page. `items` holds the items of every loaded page, and `query` loads more.
|
|
30
30
|
*/
|
|
31
31
|
export function useReviewItemsData(filters: {
|
|
32
32
|
conversationId?: string;
|
|
33
|
+
includeArchived?: boolean;
|
|
33
34
|
q?: string;
|
|
34
35
|
}) {
|
|
35
36
|
const params = new URLSearchParams();
|
|
36
37
|
if (filters.conversationId) {
|
|
37
38
|
params.set("conversationId", filters.conversationId);
|
|
38
39
|
}
|
|
40
|
+
if (filters.includeArchived) params.set("include", "archived");
|
|
39
41
|
if (filters.q) params.set("q", filters.q);
|
|
40
42
|
const search = params.toString();
|
|
41
43
|
const query = useInfiniteQuery({
|