@sentry/junior-dashboard 0.274.0 → 0.276.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 +28 -9
- package/dist/client.js +115 -92
- package/dist/index.js +28 -9
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/app.ts +8 -3
- package/src/client/App.tsx +11 -0
- package/src/client/components/Metric.tsx +0 -32
- package/src/client/components/ProfileMenu.tsx +56 -4
- package/src/client/components/layout/DashboardChrome.tsx +3 -3
- package/src/client/components/layout/DashboardHeader.tsx +41 -4
- package/src/client/components/layout/NavRail.tsx +43 -40
- package/src/client/conversations/ConversationComposer.tsx +42 -26
- package/src/client/conversations/ConversationDetails.tsx +149 -166
- package/src/client/conversations/ConversationHeader.tsx +126 -132
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
- package/src/client/conversations/ConversationMemories.tsx +118 -78
- package/src/client/conversations/ConversationMeta.tsx +94 -84
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +197 -422
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +5 -7
- package/src/client/conversations/ConversationTranscript.tsx +37 -8
- package/src/client/conversations/ConversationWatches.tsx +37 -15
- package/src/client/conversations/PendingMailboxStack.tsx +11 -1
- package/src/client/conversations/QuickQuestion.tsx +476 -0
- package/src/client/conversations/TelemetryMetrics.tsx +2 -15
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptEventView.tsx +25 -2
- package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
- package/src/client/conversations/TranscriptMessageView.tsx +46 -16
- package/src/client/conversations/TranscriptRows.tsx +9 -2
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +4 -18
- package/src/client/conversations/conversationMemory.ts +138 -0
- package/src/client/conversations/conversationSections.ts +18 -14
- package/src/client/conversations/eventTranscript.ts +8 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- package/src/client/review-desk/ReviewItemActions.tsx +1 -1
- package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
- package/src/client/review-desk/ReviewItemContext.tsx +41 -23
- package/src/client/review-desk/reviewItemFormat.ts +16 -0
- package/src/client/sentry.ts +77 -0
- package/src/client/types.ts +6 -0
- package/src/client.tsx +3 -0
- package/src/mock-reporting/watches.ts +2 -0
- package/src/shell.ts +27 -2
- package/src/tailwind.css +12 -19
- package/src/client/components/ActiveIndicator.tsx +0 -14
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ChevronRight, Inbox } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { LoadMorePagination } from "../components/Pagination";
|
|
6
|
+
import {
|
|
7
|
+
DetailsCard,
|
|
8
|
+
DetailsList,
|
|
9
|
+
DetailsRow,
|
|
10
|
+
} from "../conversations/DetailsList";
|
|
11
|
+
import { cn, dashboardDisclosureChevronClass } from "../styles";
|
|
12
|
+
import { ReviewItemCard } from "./ReviewItemCard";
|
|
13
|
+
import { useReviewItemsData } from "./queries";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Show the open Review items linked to a Conversation in its details. Each
|
|
17
|
+
* item is one line, like its sidebar chip, and expands to the full card in
|
|
18
|
+
* place. `highlight` marks the card when a sidebar link opened it.
|
|
19
|
+
*/
|
|
20
|
+
export function ConversationReviewItems(props: {
|
|
21
|
+
conversationId: string;
|
|
22
|
+
highlight: boolean;
|
|
23
|
+
}) {
|
|
24
|
+
const { items, query } = useReviewItemsData({
|
|
25
|
+
conversationId: props.conversationId,
|
|
26
|
+
});
|
|
27
|
+
const sectionRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const shown = items.length > 0;
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (props.highlight && shown) {
|
|
31
|
+
sectionRef.current?.scrollIntoView({ block: "nearest" });
|
|
32
|
+
}
|
|
33
|
+
}, [props.highlight, shown]);
|
|
34
|
+
if (!shown) return null;
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
"rounded-lg",
|
|
39
|
+
props.highlight && "ring-2 ring-violet-300/50",
|
|
40
|
+
)}
|
|
41
|
+
ref={sectionRef}
|
|
42
|
+
>
|
|
43
|
+
{/* A count of loaded pages only would understate, so hide it. */}
|
|
44
|
+
<DetailsCard
|
|
45
|
+
count={query.hasNextPage ? undefined : items.length}
|
|
46
|
+
label="Needs you"
|
|
47
|
+
title="Needs you"
|
|
48
|
+
>
|
|
49
|
+
<DetailsList
|
|
50
|
+
itemKey={(item) => item.id}
|
|
51
|
+
items={items}
|
|
52
|
+
renderItem={(item) => (
|
|
53
|
+
<ReviewItemRow defaultExpanded={props.highlight} item={item} />
|
|
54
|
+
)}
|
|
55
|
+
/>
|
|
56
|
+
<LoadMorePagination
|
|
57
|
+
className="mt-2"
|
|
58
|
+
hasMore={Boolean(query.hasNextPage)}
|
|
59
|
+
loading={query.isFetchingNextPage}
|
|
60
|
+
onLoadMore={() => void query.fetchNextPage()}
|
|
61
|
+
/>
|
|
62
|
+
</DetailsCard>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ReviewItemRow(props: {
|
|
68
|
+
defaultExpanded: boolean;
|
|
69
|
+
item: ReviewItemPayload;
|
|
70
|
+
}) {
|
|
71
|
+
const [expanded, setExpanded] = useState(props.defaultExpanded);
|
|
72
|
+
// Details can already be open when a sidebar review chip highlights it.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (props.defaultExpanded) setExpanded(true);
|
|
75
|
+
}, [props.defaultExpanded]);
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<DetailsRow
|
|
79
|
+
expanded={expanded}
|
|
80
|
+
icon={<Inbox aria-hidden="true" className="size-3.5" />}
|
|
81
|
+
meta={
|
|
82
|
+
<span className="grid size-5 place-items-center">
|
|
83
|
+
<ChevronRight
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
className={dashboardDisclosureChevronClass}
|
|
86
|
+
/>
|
|
87
|
+
</span>
|
|
88
|
+
}
|
|
89
|
+
onToggle={() => setExpanded((value) => !value)}
|
|
90
|
+
title={props.item.title}
|
|
91
|
+
/>
|
|
92
|
+
{expanded ? (
|
|
93
|
+
<div className="px-1.5 pb-1">
|
|
94
|
+
<ReviewItemCard item={props.item} />
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -26,7 +26,7 @@ function actionError(error: unknown): string {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Show the submitted answer as one line for each answered field. */
|
|
29
|
-
function AnswerSummary(props: {
|
|
29
|
+
export function AnswerSummary(props: {
|
|
30
30
|
answer: NonNullable<ReviewItemPayload["answer"]>;
|
|
31
31
|
request: NonNullable<ReviewItemPayload["request"]>;
|
|
32
32
|
}) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Inbox } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Link } from "react-router";
|
|
4
|
+
import type {
|
|
5
|
+
ReviewItemChangeRecord,
|
|
6
|
+
ReviewItemPayload,
|
|
7
|
+
} from "@sentry/junior/api/schema";
|
|
8
|
+
|
|
9
|
+
import { AnswerSummary } from "./ReviewItemActions";
|
|
10
|
+
import { useReviewItemData } from "./queries";
|
|
11
|
+
import { reviewItemPath } from "./reviewItemFormat";
|
|
12
|
+
|
|
13
|
+
const CHANGE_VERBS = {
|
|
14
|
+
dismissed: "Dismissed",
|
|
15
|
+
submitted: "Answered",
|
|
16
|
+
taken: "Took",
|
|
17
|
+
} as const satisfies Record<ReviewItemChangeRecord["kind"], string>;
|
|
18
|
+
|
|
19
|
+
function ChangeRow(props: {
|
|
20
|
+
item: ReviewItemPayload;
|
|
21
|
+
record: ReviewItemChangeRecord;
|
|
22
|
+
}) {
|
|
23
|
+
const { item, record } = props;
|
|
24
|
+
const verb = CHANGE_VERBS[record.kind];
|
|
25
|
+
return (
|
|
26
|
+
<section
|
|
27
|
+
aria-label={`${verb} ${item.title}`}
|
|
28
|
+
className="grid min-w-0 max-w-[37.5rem] gap-2 rounded-lg border border-dashboard-border bg-dashboard-surface-panel p-3 font-sans text-sm"
|
|
29
|
+
>
|
|
30
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
31
|
+
<Inbox
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
className="shrink-0 text-dashboard-text-muted"
|
|
34
|
+
size={15}
|
|
35
|
+
/>
|
|
36
|
+
<span className="shrink-0 text-dashboard-text-muted">{verb}</span>
|
|
37
|
+
<Link
|
|
38
|
+
className="min-w-0 flex-1 truncate font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus"
|
|
39
|
+
to={reviewItemPath(item.id)}
|
|
40
|
+
>
|
|
41
|
+
{item.title}
|
|
42
|
+
</Link>
|
|
43
|
+
</div>
|
|
44
|
+
{/* An answer is final, so the item's answer is this message's answer. */}
|
|
45
|
+
{record.kind === "submitted" && item.request && item.answer ? (
|
|
46
|
+
<AnswerSummary answer={item.answer} request={item.request} />
|
|
47
|
+
) : null}
|
|
48
|
+
</section>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Show the Review item change that a message records as one row: what the
|
|
54
|
+
* message did and the item it did it to. The row shows the change from the
|
|
55
|
+
* message, not the item's current state, so a take stays a take after a
|
|
56
|
+
* later answer. Shows `fallback` when the viewer cannot read the item.
|
|
57
|
+
*/
|
|
58
|
+
export function ReviewItemChangeRow(props: {
|
|
59
|
+
fallback: ReactNode;
|
|
60
|
+
record: ReviewItemChangeRecord;
|
|
61
|
+
}) {
|
|
62
|
+
const { data, isError } = useReviewItemData(props.record.reviewItemId);
|
|
63
|
+
if (isError) return props.fallback;
|
|
64
|
+
// The row replaces the text, so wait for the item before showing it.
|
|
65
|
+
if (!data) return null;
|
|
66
|
+
return <ChangeRow item={data} record={props.record} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Name the Review item change that a queued message records, on one line.
|
|
71
|
+
* Shows `fallback` until the item loads or when the viewer cannot read it.
|
|
72
|
+
*/
|
|
73
|
+
export function ReviewItemChangeLabel(props: {
|
|
74
|
+
fallback: ReactNode;
|
|
75
|
+
record: ReviewItemChangeRecord;
|
|
76
|
+
}) {
|
|
77
|
+
const { data } = useReviewItemData(props.record.reviewItemId);
|
|
78
|
+
if (!data) return props.fallback;
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
{CHANGE_VERBS[props.record.kind]}{" "}
|
|
82
|
+
<span className="font-medium text-dashboard-text">{data.title}</span>
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -9,28 +9,39 @@ import { ConversationTranscriptView } from "../conversations/ConversationTranscr
|
|
|
9
9
|
import { TranscriptLoading } from "../conversations/TranscriptLoading";
|
|
10
10
|
import { useConversationData } from "../conversations/queries";
|
|
11
11
|
import type { ConversationTranscript } from "../types";
|
|
12
|
+
import { excerptMessage } from "./reviewItemFormat";
|
|
12
13
|
|
|
13
14
|
/** Messages before the item that the excerpt shows. */
|
|
14
15
|
const BEFORE_COUNT = 3;
|
|
15
|
-
/** Messages after the item that the excerpt shows
|
|
16
|
-
const AFTER_COUNT =
|
|
16
|
+
/** Messages after the item that the excerpt shows. */
|
|
17
|
+
const AFTER_COUNT = 3;
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* Pick the messages nearest the time an item was added. Only messages show:
|
|
21
|
+
* the tool call that added the item would repeat its card. For a closed item,
|
|
22
|
+
* messages from its close on do not show: the card already has the answer.
|
|
21
23
|
*/
|
|
22
24
|
function conversationExcerpt(
|
|
23
25
|
events: readonly ConversationReportEvent[],
|
|
24
|
-
|
|
26
|
+
item: Pick<ReviewItemPayload, "closedAt" | "createdAt">,
|
|
25
27
|
): { after: ConversationReportEvent[]; before: ConversationReportEvent[] } {
|
|
26
|
-
const askedAt = Date.parse(
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
)
|
|
28
|
+
const askedAt = Date.parse(item.createdAt);
|
|
29
|
+
const closedAt = item.closedAt ? Date.parse(item.closedAt) : Infinity;
|
|
30
|
+
// Changes to items show on their cards, not as context for this one.
|
|
31
|
+
const messages = events.flatMap((event) => {
|
|
32
|
+
if (event.data.type !== "message" || event.data.role === "system") {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const message = excerptMessage(event);
|
|
36
|
+
return message ? [message] : [];
|
|
37
|
+
});
|
|
30
38
|
return {
|
|
31
39
|
after: messages
|
|
32
|
-
.filter((event) =>
|
|
33
|
-
|
|
40
|
+
.filter((event) => {
|
|
41
|
+
const at = Date.parse(event.createdAt);
|
|
42
|
+
return at > askedAt && at < closedAt;
|
|
43
|
+
})
|
|
44
|
+
.slice(0, AFTER_COUNT),
|
|
34
45
|
before: messages
|
|
35
46
|
.filter((event) => Date.parse(event.createdAt) <= askedAt)
|
|
36
47
|
.slice(-BEFORE_COUNT),
|
|
@@ -49,8 +60,8 @@ function Excerpt(props: {
|
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
/**
|
|
52
|
-
* Show the
|
|
53
|
-
* asked. Returns `fallback` when the viewer cannot read its messages.
|
|
63
|
+
* Show the messages around the point where the agent added an item, split
|
|
64
|
+
* where it asked. Returns `fallback` when the viewer cannot read its messages.
|
|
54
65
|
*/
|
|
55
66
|
export function ReviewItemContext(props: {
|
|
56
67
|
fallback: ReactNode;
|
|
@@ -60,30 +71,37 @@ export function ReviewItemContext(props: {
|
|
|
60
71
|
const data = detail.data;
|
|
61
72
|
const events = data?.events;
|
|
62
73
|
const excerpt = useMemo(
|
|
63
|
-
() =>
|
|
64
|
-
|
|
65
|
-
[events, props.item.createdAt],
|
|
74
|
+
() => (events ? conversationExcerpt(events, props.item) : undefined),
|
|
75
|
+
[events, props.item],
|
|
66
76
|
);
|
|
67
77
|
// The first page holds the newest events. Load earlier pages until the
|
|
68
|
-
// page reaches the time the item was added, or history ends.
|
|
78
|
+
// page reaches the time the item was added, or history ends. Until then
|
|
79
|
+
// the loaded messages after the ask are not the first ones, so the
|
|
80
|
+
// excerpt waits.
|
|
69
81
|
const loadPreviousPageRef = useRef(detail.loadPreviousPage);
|
|
70
82
|
loadPreviousPageRef.current = detail.loadPreviousPage;
|
|
71
83
|
const oldest = events?.[0]?.createdAt;
|
|
72
|
-
|
|
84
|
+
// Redacted or expired history has no messages to wait for.
|
|
85
|
+
const available = data?.eventHistory.status === "available";
|
|
86
|
+
const missingEarlier =
|
|
87
|
+
available &&
|
|
73
88
|
oldest !== undefined &&
|
|
74
89
|
Date.parse(oldest) > Date.parse(props.item.createdAt) &&
|
|
75
|
-
detail.hasPreviousPage
|
|
76
|
-
|
|
77
|
-
!detail.historyError;
|
|
90
|
+
detail.hasPreviousPage;
|
|
91
|
+
const loadEarlier =
|
|
92
|
+
missingEarlier && !detail.isLoadingPreviousPage && !detail.historyError;
|
|
78
93
|
useEffect(() => {
|
|
79
94
|
if (loadEarlier) loadPreviousPageRef.current();
|
|
80
95
|
}, [loadEarlier, oldest]);
|
|
81
96
|
|
|
82
|
-
if (detail.isPending
|
|
97
|
+
if (detail.isPending || (missingEarlier && !detail.historyError)) {
|
|
98
|
+
return <TranscriptLoading />;
|
|
99
|
+
}
|
|
83
100
|
if (
|
|
84
101
|
!data ||
|
|
85
102
|
!excerpt ||
|
|
86
|
-
|
|
103
|
+
!available ||
|
|
104
|
+
missingEarlier ||
|
|
87
105
|
excerpt.before.length + excerpt.after.length === 0
|
|
88
106
|
) {
|
|
89
107
|
return props.fallback;
|
|
@@ -91,6 +91,22 @@ export function latestReviewItemId(
|
|
|
91
91
|
return undefined;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Return a message for another item's excerpt, without the Review item
|
|
96
|
+
* changes that it records: those show on their own items. Returns undefined
|
|
97
|
+
* when nothing else remains.
|
|
98
|
+
*/
|
|
99
|
+
export function excerptMessage(
|
|
100
|
+
event: ConversationReportEvent,
|
|
101
|
+
): ConversationReportEvent | undefined {
|
|
102
|
+
const { data } = event;
|
|
103
|
+
if (data.type !== "message" || !data.reviewItemChanges) return event;
|
|
104
|
+
// A message with changes lists its own Events. With none, it only
|
|
105
|
+
// records changes.
|
|
106
|
+
if (!data.events) return undefined;
|
|
107
|
+
return { ...event, data: { ...data, reviewItemChanges: undefined } };
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
/** True when an open item asks a question that the viewer can answer. */
|
|
95
111
|
export function reviewItemWaitsForAnswer(item: ReviewItemPayload): boolean {
|
|
96
112
|
return (
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
browserTracingIntegration,
|
|
3
|
+
init,
|
|
4
|
+
type BrowserOptions,
|
|
5
|
+
} from "@sentry/browser";
|
|
6
|
+
import type { BrowserSentryConfig } from "@sentry/junior/instrumentation";
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface Window {
|
|
10
|
+
__JUNIOR_DASHBOARD_SENTRY__?: Omit<
|
|
11
|
+
BrowserSentryConfig,
|
|
12
|
+
"baggage" | "sentryTrace"
|
|
13
|
+
>;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type StreamedSpanJSON = Parameters<
|
|
18
|
+
NonNullable<BrowserOptions["beforeSendSpan"]>
|
|
19
|
+
>[0];
|
|
20
|
+
|
|
21
|
+
const QUERY_ATTRIBUTE_KEYS = [
|
|
22
|
+
"http.fragment",
|
|
23
|
+
"http.query",
|
|
24
|
+
"url.fragment",
|
|
25
|
+
"url.query",
|
|
26
|
+
];
|
|
27
|
+
// Element selectors in interaction and web vital attributes include these
|
|
28
|
+
// attribute values, such as a conversation title in an `aria-label`. The SDK
|
|
29
|
+
// does not escape quotes in values, so a value can contain `"]` and fake
|
|
30
|
+
// selector parts. Cutting from the first marker to the last `"]` removes every
|
|
31
|
+
// value, at the cost of the selector parts between them.
|
|
32
|
+
const ELEMENT_ATTRIBUTE_START = /\[(?:aria-label|type|name|title|alt)="/;
|
|
33
|
+
|
|
34
|
+
function scrubSelector(value: string): string {
|
|
35
|
+
const start = value.search(ELEMENT_ATTRIBUTE_START);
|
|
36
|
+
if (start === -1) return value;
|
|
37
|
+
const end = value.lastIndexOf('"]');
|
|
38
|
+
return end < start
|
|
39
|
+
? value.slice(0, start)
|
|
40
|
+
: value.slice(0, start) + value.slice(end + 2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scrubAttribute(value: string): string {
|
|
44
|
+
// Page and API URLs carry search text in the query string.
|
|
45
|
+
if (/^https?:\/\//.test(value)) return value.split(/[?#]/, 1)[0] ?? value;
|
|
46
|
+
return scrubSelector(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Browser span names are fixed labels such as "Click" or "GET localhost". The
|
|
50
|
+
// page content they describe is in their attributes.
|
|
51
|
+
function scrubSpan(span: StreamedSpanJSON): StreamedSpanJSON {
|
|
52
|
+
for (const key of QUERY_ATTRIBUTE_KEYS) {
|
|
53
|
+
delete span.attributes[key];
|
|
54
|
+
}
|
|
55
|
+
for (const [key, value] of Object.entries(span.attributes)) {
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
span.attributes[key] = scrubAttribute(value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return span;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Send dashboard performance spans to the server's Sentry project when the
|
|
65
|
+
* server provides a DSN. Only tracing is enabled: error events and
|
|
66
|
+
* breadcrumbs could carry conversation content shown on the page.
|
|
67
|
+
*/
|
|
68
|
+
export function initDashboardSentry(): void {
|
|
69
|
+
const config = window.__JUNIOR_DASHBOARD_SENTRY__;
|
|
70
|
+
if (!config) return;
|
|
71
|
+
init({
|
|
72
|
+
...config,
|
|
73
|
+
beforeSendSpan: scrubSpan,
|
|
74
|
+
defaultIntegrations: false,
|
|
75
|
+
integrations: [browserTracingIntegration()],
|
|
76
|
+
});
|
|
77
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ConversationSummaryReport,
|
|
16
16
|
ConversationTurnFailureCode,
|
|
17
17
|
ConversationTurnFailureReason,
|
|
18
|
+
ReviewItemChangeRecord,
|
|
18
19
|
} from "@sentry/junior/api/schema";
|
|
19
20
|
import type { ConversationDetailReport } from "@sentry/junior/api/schema";
|
|
20
21
|
import type { ConversationEventPresentation } from "@sentry/junior-plugin-api";
|
|
@@ -90,6 +91,8 @@ export type TranscriptViewStructuredEventPart = {
|
|
|
90
91
|
name: string;
|
|
91
92
|
namespace: string;
|
|
92
93
|
presentation: ConversationEventPresentation;
|
|
94
|
+
/** Turn that emitted the event. */
|
|
95
|
+
turnId?: string;
|
|
93
96
|
type: "structured_event";
|
|
94
97
|
version: number;
|
|
95
98
|
};
|
|
@@ -113,6 +116,7 @@ export type TranscriptViewTurnContext = {
|
|
|
113
116
|
kind: string;
|
|
114
117
|
loadedAt: string;
|
|
115
118
|
pluginName: string;
|
|
119
|
+
turnId: string;
|
|
116
120
|
version: number;
|
|
117
121
|
};
|
|
118
122
|
|
|
@@ -134,6 +138,8 @@ export type TranscriptViewMessage = {
|
|
|
134
138
|
events?: ConversationReportMessageEvent[];
|
|
135
139
|
/** Whether the source message addressed Junior directly. */
|
|
136
140
|
explicitMention?: boolean;
|
|
141
|
+
/** Person changes to Review items that this message records. */
|
|
142
|
+
reviewItemChanges?: ReviewItemChangeRecord[];
|
|
137
143
|
/** People mentioned in the event text, resolved by the API. */
|
|
138
144
|
mentions?: ActorIdentity[];
|
|
139
145
|
/** Whether a non-mention message was used as input to a turn. */
|
package/src/client.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Toaster } from "./client/components/ui/toast";
|
|
|
7
7
|
import { TooltipProvider } from "./client/components/ui/tooltip";
|
|
8
8
|
import { DashboardShell } from "./client/App";
|
|
9
9
|
import { getDashboardAgentName } from "./client/agentName";
|
|
10
|
+
import { initDashboardSentry } from "./client/sentry";
|
|
10
11
|
|
|
11
12
|
declare global {
|
|
12
13
|
interface Window {
|
|
@@ -66,6 +67,8 @@ function DashboardErrorPanel(props: { error: Error }) {
|
|
|
66
67
|
);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
initDashboardSentry();
|
|
71
|
+
|
|
69
72
|
const root = document.getElementById("dashboard-root");
|
|
70
73
|
if (!root) {
|
|
71
74
|
throw new Error("Junior dashboard root element was not found");
|
|
@@ -43,6 +43,7 @@ export function readMockConversationWatches(
|
|
|
43
43
|
label: "getsentry/junior#2383",
|
|
44
44
|
namespace: "github",
|
|
45
45
|
resourceType: "pull_request",
|
|
46
|
+
url: "https://github.com/getsentry/junior/pull/2383",
|
|
46
47
|
},
|
|
47
48
|
{
|
|
48
49
|
createdAt: iso(-2 * HOUR_MS),
|
|
@@ -79,6 +80,7 @@ export function readMockConversationWatches(
|
|
|
79
80
|
label: "getsentry/junior#2351",
|
|
80
81
|
namespace: "github",
|
|
81
82
|
resourceType: "pull_request",
|
|
83
|
+
url: "https://github.com/getsentry/junior/pull/2351",
|
|
82
84
|
},
|
|
83
85
|
{
|
|
84
86
|
createdAt: iso(-10 * 60_000),
|
package/src/shell.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import type { BrowserSentryConfig } from "@sentry/junior/instrumentation";
|
|
3
4
|
import {
|
|
4
5
|
dashboardAvatarHeaderAsset,
|
|
5
6
|
dashboardFaviconAsset,
|
|
@@ -210,9 +211,32 @@ export function renderInstallIcon(): Response {
|
|
|
210
211
|
});
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
function scriptJson(value: unknown): string {
|
|
215
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The browser SDK reads these meta tags to continue the trace of the request
|
|
219
|
+
// that served this page, so its pageload span joins the server spans.
|
|
220
|
+
function renderSentryHead(sentry: BrowserSentryConfig | undefined): string {
|
|
221
|
+
if (!sentry) return "";
|
|
222
|
+
const { baggage, sentryTrace, ...options } = sentry;
|
|
223
|
+
return [
|
|
224
|
+
sentryTrace &&
|
|
225
|
+
`<meta name="sentry-trace" content="${escapeHtml(sentryTrace)}" />`,
|
|
226
|
+
baggage && `<meta name="baggage" content="${escapeHtml(baggage)}" />`,
|
|
227
|
+
`<script>window.__JUNIOR_DASHBOARD_SENTRY__ = ${scriptJson(options)};</script>`,
|
|
228
|
+
]
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
.join("\n ");
|
|
231
|
+
}
|
|
232
|
+
|
|
213
233
|
/** Render the authenticated dashboard HTML shell. */
|
|
214
|
-
export function renderDashboard(
|
|
215
|
-
|
|
234
|
+
export function renderDashboard(
|
|
235
|
+
basePath: string,
|
|
236
|
+
agentName: string,
|
|
237
|
+
sentry?: BrowserSentryConfig,
|
|
238
|
+
): Response {
|
|
239
|
+
const encodedAgentName = scriptJson(agentName);
|
|
216
240
|
return new Response(
|
|
217
241
|
`<!DOCTYPE html>
|
|
218
242
|
<html lang="en">
|
|
@@ -228,6 +252,7 @@ export function renderDashboard(basePath: string, agentName: string): Response {
|
|
|
228
252
|
<link rel="manifest" href="${DASHBOARD_MANIFEST_PATH}" />
|
|
229
253
|
<link rel="apple-touch-icon" href="${DASHBOARD_INSTALL_ICON_PATH}" />
|
|
230
254
|
<title>${escapeHtml(agentName)}</title>
|
|
255
|
+
${renderSentryHead(sentry)}
|
|
231
256
|
<style>
|
|
232
257
|
${readDashboardTailwind()}
|
|
233
258
|
</style>
|
package/src/tailwind.css
CHANGED
|
@@ -294,6 +294,10 @@
|
|
|
294
294
|
/* Style one-off values that the token and palette mirror cannot cover. */
|
|
295
295
|
@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
|
|
296
296
|
|
|
297
|
+
/* Desktop windows tall enough to afford the top bar. Shorter windows, such as
|
|
298
|
+
most laptops, keep the nav rail so conversations get the height. */
|
|
299
|
+
@custom-variant tall (@media (min-height: 62.5rem));
|
|
300
|
+
|
|
297
301
|
@layer base {
|
|
298
302
|
:root {
|
|
299
303
|
color-scheme: var(--dashboard-color-scheme, dark);
|
|
@@ -338,30 +342,19 @@
|
|
|
338
342
|
}
|
|
339
343
|
}
|
|
340
344
|
|
|
341
|
-
@keyframes junior-active-indicator {
|
|
342
|
-
0%,
|
|
343
|
-
100% {
|
|
344
|
-
box-shadow:
|
|
345
|
-
0 0 0 0 rgba(110, 231, 183, 0),
|
|
346
|
-
0 0 8px rgba(110, 231, 183, 0.35);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
50% {
|
|
350
|
-
box-shadow:
|
|
351
|
-
0 0 0 4px rgba(110, 231, 183, 0.18),
|
|
352
|
-
0 0 14px rgba(110, 231, 183, 0.65);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
345
|
@media (max-width: 767px) {
|
|
357
346
|
/* Keep long mobile feeds cheap while preserving their full scroll range. */
|
|
358
347
|
.mobile-conversation-row {
|
|
359
348
|
content-visibility: auto;
|
|
360
349
|
contain-intrinsic-size: auto 3.5rem;
|
|
361
350
|
}
|
|
351
|
+
}
|
|
362
352
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
353
|
+
/* The Quick question selection. Utilities cannot style a highlight. */
|
|
354
|
+
::highlight(quick-question) {
|
|
355
|
+
background-color: color-mix(
|
|
356
|
+
in oklab,
|
|
357
|
+
var(--color-dashboard-accent) 30%,
|
|
358
|
+
transparent
|
|
359
|
+
);
|
|
367
360
|
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { cn } from "../styles";
|
|
2
|
-
|
|
3
|
-
/** Render the animated dot used to show active work. */
|
|
4
|
-
export function ActiveIndicator(props: { className?: string }) {
|
|
5
|
-
return (
|
|
6
|
-
<span
|
|
7
|
-
aria-hidden="true"
|
|
8
|
-
className={cn(
|
|
9
|
-
"shrink-0 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(110,231,183,0.55)] animate-[junior-active-indicator_1.8s_ease-in-out_infinite] motion-reduce:animate-none",
|
|
10
|
-
props.className,
|
|
11
|
-
)}
|
|
12
|
-
/>
|
|
13
|
-
);
|
|
14
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
/** Render the compact owner and runtime line below a conversation title. */
|
|
4
|
-
export function ConversationHeaderMeta(props: {
|
|
5
|
-
identity: ReactNode;
|
|
6
|
-
stats: ReactNode;
|
|
7
|
-
}) {
|
|
8
|
-
if (!props.identity && !props.stats) return null;
|
|
9
|
-
return (
|
|
10
|
-
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
|
|
11
|
-
{props.identity ? (
|
|
12
|
-
<span className="min-w-0 max-w-full truncate">{props.identity}</span>
|
|
13
|
-
) : null}
|
|
14
|
-
{props.identity && props.stats ? (
|
|
15
|
-
<span className="text-dashboard-text-muted">·</span>
|
|
16
|
-
) : null}
|
|
17
|
-
{props.stats ? <span className="min-w-0">{props.stats}</span> : null}
|
|
18
|
-
</div>
|
|
19
|
-
);
|
|
20
|
-
}
|