@sentry/junior-dashboard 0.269.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 +78 -75
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/client/App.tsx +103 -65
- package/src/client/components/AGENTS.md +30 -59
- package/src/client/components/AutomationCard.tsx +2 -1
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +143 -39
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/AGENTS.md +12 -37
- package/src/client/conversations/ConversationBrief.tsx +55 -36
- package/src/client/conversations/ConversationComposer.tsx +27 -8
- package/src/client/conversations/ConversationEventLog.tsx +1 -0
- package/src/client/conversations/ConversationHomeList.tsx +43 -26
- package/src/client/conversations/ConversationPage.tsx +137 -40
- package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
- package/src/client/conversations/ConversationSidebar.tsx +55 -25
- package/src/client/conversations/ConversationTranscript.tsx +3 -2
- package/src/client/conversations/ConversationWatches.tsx +4 -1
- package/src/client/conversations/ConversationWorkspace.tsx +43 -25
- package/src/client/conversations/EventDetails.tsx +6 -9
- package/src/client/conversations/PendingMailboxStack.tsx +5 -2
- package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
- package/src/client/conversations/TranscriptEventView.tsx +9 -3
- package/src/client/conversations/TranscriptMessageView.tsx +6 -5
- package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
- package/src/client/conversations/TranscriptRows.tsx +6 -1
- package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
- package/src/client/conversations/TranscriptToolView.tsx +2 -1
- package/src/client/conversations/TranscriptView.tsx +6 -6
- package/src/client/conversations/conversationOutbox.ts +13 -3
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/conversations/conversationSections.ts +36 -8
- package/src/client/conversations/eventTranscript.ts +1 -0
- package/src/client/conversations/transcriptBottomPinning.ts +171 -81
- package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
- package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
- package/src/client/display-text/DisplayText.tsx +68 -0
- package/src/client/display-text/emoji.ts +54 -0
- package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
- package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
- package/src/client/display-text/mentions.tsx +148 -0
- package/src/client/display-text/plainText.ts +29 -0
- package/src/client/pages/automations/AGENTS.md +6 -25
- package/src/client/pages/automations/AutomationFilters.tsx +6 -6
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
- package/src/client/pages/code/RepositoryTabs.tsx +6 -1
- package/src/client/pages/memory/MemoryDetails.tsx +5 -13
- 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/ConversationReviewBar.tsx +0 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +202 -241
- package/src/client/review-desk/ReviewDeskSidebar.tsx +253 -0
- package/src/client/review-desk/ReviewItemActions.tsx +6 -2
- package/src/client/review-desk/ReviewItemCard.tsx +58 -17
- 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/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/queries.ts +4 -2
- package/src/client/review-desk/reviewItemFormat.ts +83 -1
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/conversations/TranscriptText.tsx +0 -47
- package/src/client/conversations/conversationActivityPreview.ts +0 -16
- package/src/client/conversations/transcriptMentions.tsx +0 -134
- 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,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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
AlertDialog,
|
|
16
|
+
AlertDialogAction,
|
|
17
|
+
AlertDialogCancel,
|
|
18
|
+
AlertDialogContent,
|
|
19
|
+
AlertDialogDescription,
|
|
20
|
+
AlertDialogFooter,
|
|
21
|
+
AlertDialogTitle,
|
|
22
|
+
} from "../components/ui/alert-dialog";
|
|
23
|
+
|
|
24
|
+
type UnsentAnswers = {
|
|
25
|
+
conversationId: string;
|
|
26
|
+
/** Item title for each mounted answer form that has unsent answers. */
|
|
27
|
+
titles: Map<string, string>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const UnsentAnswersContext = createContext<UnsentAnswers | undefined>(
|
|
31
|
+
undefined,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Track the answer forms in one Conversation that have unsent answers. A
|
|
36
|
+
* message in the Conversation closes some open action items, and the
|
|
37
|
+
* answers on those forms are then lost. The reply footer reads this to ask
|
|
38
|
+
* first.
|
|
39
|
+
*/
|
|
40
|
+
export function UnsentAnswersProvider(props: {
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
conversationId: string;
|
|
43
|
+
}) {
|
|
44
|
+
const value = useMemo(
|
|
45
|
+
() => ({ conversationId: props.conversationId, titles: new Map() }),
|
|
46
|
+
[props.conversationId],
|
|
47
|
+
);
|
|
48
|
+
return (
|
|
49
|
+
<UnsentAnswersContext.Provider value={value}>
|
|
50
|
+
{props.children}
|
|
51
|
+
</UnsentAnswersContext.Provider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Report that the answer form of an item has unsent answers. It counts only
|
|
57
|
+
* when a message from the viewer closes the item. This follows
|
|
58
|
+
* `closeReviewItemsAnsweredByReply`: an open action item that the agent
|
|
59
|
+
* added in this Conversation, not assigned to Everyone, that the viewer can
|
|
60
|
+
* act on. Outside a Conversation it does nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function useReportUnsentAnswer(
|
|
63
|
+
item: ReviewItemPayload,
|
|
64
|
+
unsent: boolean,
|
|
65
|
+
) {
|
|
66
|
+
const context = useContext(UnsentAnswersContext);
|
|
67
|
+
const key = useId();
|
|
68
|
+
const closesOnReply =
|
|
69
|
+
item.kind === "action" &&
|
|
70
|
+
item.status === "open" &&
|
|
71
|
+
item.canAct &&
|
|
72
|
+
!item.assignedToEveryone &&
|
|
73
|
+
item.addedByConversationId === context?.conversationId;
|
|
74
|
+
const title = item.title;
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!context || !closesOnReply || !unsent) return;
|
|
77
|
+
context.titles.set(key, title);
|
|
78
|
+
return () => {
|
|
79
|
+
context.titles.delete(key);
|
|
80
|
+
};
|
|
81
|
+
}, [closesOnReply, context, key, title, unsent]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ask the reader before a message drops unsent answers. `confirmSubmit`
|
|
86
|
+
* returns undefined when nothing would be lost, so the send goes at once.
|
|
87
|
+
* Otherwise it opens `dialog` and resolves true when the reader sends anyway.
|
|
88
|
+
*/
|
|
89
|
+
export function useConfirmUnsentAnswers() {
|
|
90
|
+
const context = useContext(UnsentAnswersContext);
|
|
91
|
+
const [open, setOpen] = useState(false);
|
|
92
|
+
// Keep the titles through the close animation.
|
|
93
|
+
const [titles, setTitles] = useState<string[]>([]);
|
|
94
|
+
const resolveRef = useRef<(send: boolean) => void>(undefined);
|
|
95
|
+
|
|
96
|
+
const confirmSubmit = useCallback(() => {
|
|
97
|
+
// The transcript card and the side panel can show the same item.
|
|
98
|
+
const unsent = [...new Set(context?.titles.values())];
|
|
99
|
+
if (unsent.length === 0) return undefined;
|
|
100
|
+
setTitles(unsent);
|
|
101
|
+
setOpen(true);
|
|
102
|
+
return new Promise<boolean>((resolve) => {
|
|
103
|
+
resolveRef.current = resolve;
|
|
104
|
+
});
|
|
105
|
+
}, [context]);
|
|
106
|
+
|
|
107
|
+
// Resolve on close, so a dismissal by Escape also keeps the draft.
|
|
108
|
+
const close = (send: boolean) => {
|
|
109
|
+
resolveRef.current?.(send);
|
|
110
|
+
resolveRef.current = undefined;
|
|
111
|
+
setOpen(false);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const dialog = (
|
|
115
|
+
<AlertDialog
|
|
116
|
+
onOpenChange={(open) => {
|
|
117
|
+
if (!open) close(false);
|
|
118
|
+
}}
|
|
119
|
+
open={open}
|
|
120
|
+
>
|
|
121
|
+
<AlertDialogContent>
|
|
122
|
+
<AlertDialogTitle>Send without your answers?</AlertDialogTitle>
|
|
123
|
+
<AlertDialogDescription>
|
|
124
|
+
{titles.length === 1 ? (
|
|
125
|
+
<>
|
|
126
|
+
Your answers to{" "}
|
|
127
|
+
<strong className="font-medium text-dashboard-text">
|
|
128
|
+
{titles[0]}
|
|
129
|
+
</strong>{" "}
|
|
130
|
+
are not sent. This message closes that review item, and the
|
|
131
|
+
answers are lost.
|
|
132
|
+
</>
|
|
133
|
+
) : (
|
|
134
|
+
`Your answers to ${titles.length} review items are not sent. This message closes those items, and the answers are lost.`
|
|
135
|
+
)}
|
|
136
|
+
</AlertDialogDescription>
|
|
137
|
+
<AlertDialogFooter>
|
|
138
|
+
<AlertDialogCancel>Go back</AlertDialogCancel>
|
|
139
|
+
<AlertDialogAction onClick={() => close(true)} variant="danger">
|
|
140
|
+
Send message
|
|
141
|
+
</AlertDialogAction>
|
|
142
|
+
</AlertDialogFooter>
|
|
143
|
+
</AlertDialogContent>
|
|
144
|
+
</AlertDialog>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return { confirmSubmit, dialog };
|
|
148
|
+
}
|
|
@@ -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({
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ConversationReportEvent,
|
|
4
|
+
ReviewItemPayload,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
3
6
|
|
|
4
7
|
/** Return the review desk route that selects one item. */
|
|
5
8
|
export function reviewItemPath(id: string): string {
|
|
6
9
|
return `/review?item=${encodeURIComponent(id)}`;
|
|
7
10
|
}
|
|
8
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Return the subject URL when it is safe to link. Agents and plugins supply
|
|
14
|
+
* it, and URL validation also accepts `javascript:` and `data:` URLs.
|
|
15
|
+
*/
|
|
16
|
+
export function reviewItemSubjectHref(url: string): string | undefined {
|
|
17
|
+
try {
|
|
18
|
+
const { protocol } = new URL(url);
|
|
19
|
+
return protocol === "https:" || protocol === "http:" ? url : undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/** Name the state of an item: what it needs while open, or how it closed. */
|
|
10
26
|
export function reviewItemStatusLabel(item: ReviewItemPayload): string {
|
|
11
27
|
if (item.status === "open") {
|
|
@@ -59,3 +75,69 @@ export function reviewItemIdFromToolCall(call: {
|
|
|
59
75
|
if (toolName !== ADD_REVIEW_ITEM_TOOL_NAME) return undefined;
|
|
60
76
|
return addedItemId(call.output);
|
|
61
77
|
}
|
|
78
|
+
|
|
79
|
+
/** Return the id of the last Review item that a tool call in the events added. */
|
|
80
|
+
export function latestReviewItemId(
|
|
81
|
+
events: readonly ConversationReportEvent[] | undefined,
|
|
82
|
+
): string | undefined {
|
|
83
|
+
for (let index = (events?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
84
|
+
const data = events![index]!.data;
|
|
85
|
+
if (data.type !== "tool_calls") continue;
|
|
86
|
+
for (let call = data.calls.length - 1; call >= 0; call -= 1) {
|
|
87
|
+
const id = reviewItemIdFromToolCall(data.calls[call]!);
|
|
88
|
+
if (id) return id;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when an open item asks a question that the viewer can answer. */
|
|
95
|
+
export function reviewItemWaitsForAnswer(item: ReviewItemPayload): boolean {
|
|
96
|
+
return (
|
|
97
|
+
item.status === "open" &&
|
|
98
|
+
item.kind === "action" &&
|
|
99
|
+
Boolean(item.request) &&
|
|
100
|
+
item.canAct
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The Review items of the inbox, in the groups and order the list shows. */
|
|
105
|
+
export type InboxGroups = {
|
|
106
|
+
archived: ReviewItemPayload[];
|
|
107
|
+
fyi: ReviewItemPayload[];
|
|
108
|
+
needsYou: ReviewItemPayload[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Inbox order: action items first, then the oldest, then the item id. */
|
|
112
|
+
function inboxOrder(a: ReviewItemPayload, b: ReviewItemPayload): number {
|
|
113
|
+
if (a.kind !== b.kind) return a.kind === "action" ? -1 : 1;
|
|
114
|
+
const age = Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
|
115
|
+
if (age !== 0) return age;
|
|
116
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Split items into inbox groups. Open items show oldest first, so the item
|
|
121
|
+
* that waited longest is at the top. Archived items keep the API order.
|
|
122
|
+
*/
|
|
123
|
+
export function inboxGroups(items: readonly ReviewItemPayload[]): InboxGroups {
|
|
124
|
+
const open = items.filter((item) => item.status === "open");
|
|
125
|
+
return {
|
|
126
|
+
archived: items.filter((item) => item.status !== "open"),
|
|
127
|
+
fyi: open.filter((item) => item.kind === "notice").sort(inboxOrder),
|
|
128
|
+
needsYou: open.filter((item) => item.kind === "action").sort(inboxOrder),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The open item that follows `current` in inbox order. `current` can be an
|
|
134
|
+
* item that closed and left the open groups.
|
|
135
|
+
*/
|
|
136
|
+
export function nextInboxItem(
|
|
137
|
+
groups: InboxGroups,
|
|
138
|
+
current: ReviewItemPayload,
|
|
139
|
+
): ReviewItemPayload | undefined {
|
|
140
|
+
return [...groups.needsYou, ...groups.fyi].find(
|
|
141
|
+
(item) => inboxOrder(current, item) < 0,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# System cache reporting
|
|
2
|
-
|
|
3
|
-
- `cache-input.ts` owns the cache input calculation.
|
|
4
|
-
- `ConversationMetricDay` comes from stored Run metrics, not Sentry spans.
|
|
5
|
-
Its input, cached input, and cache-creation counters are disjoint. Add
|
|
6
|
-
them once for total input. Do not include output. Never use this transform
|
|
7
|
-
for Sentry span totals.
|
|
8
|
-
- Period shares use summed token counts, not the mean of bucket shares.
|
|
9
|
-
- A missing counter is not a zero. When an active bucket has no counter, omit
|
|
10
|
-
its share and the whole-period share.
|
|
11
|
-
- Do not call these values a cache hit rate or a cache failure rate. Field
|
|
12
|
-
presence is not call-level coverage.
|
|
13
|
-
- The page time control selects the buckets for the chart and `CacheWrites`.
|
|
14
|
-
Do not add a local time selector.
|
|
15
|
-
- Add a reporting contract before you add per-call filters or drilldowns.
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
2
|
-
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
3
|
-
|
|
4
|
-
import { HighlightedCode } from "../code";
|
|
5
|
-
import { parseMarkdownBlocks, transcriptRoleKind } from "../format";
|
|
6
|
-
import { TranscriptMarkdown } from "./TranscriptMarkdown";
|
|
7
|
-
import { MentionPeopleProvider } from "./transcriptMentions";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Render transcript prose as Markdown with explicit fenced code blocks.
|
|
11
|
-
* `mentions` are the people that the API resolved for this text.
|
|
12
|
-
*/
|
|
13
|
-
export const TranscriptText = memo(function TranscriptText(props: {
|
|
14
|
-
mentions: readonly ActorIdentity[];
|
|
15
|
-
role?: string;
|
|
16
|
-
text: string;
|
|
17
|
-
}) {
|
|
18
|
-
const roleKind = transcriptRoleKind(props.role ?? "");
|
|
19
|
-
const blocks = parseMarkdownBlocks(props.text);
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<MentionPeopleProvider mentions={props.mentions}>
|
|
23
|
-
<div className="grid min-w-0 gap-2">
|
|
24
|
-
{blocks.map((block, index) => {
|
|
25
|
-
if (block.language === "markdown" && !block.fenced) {
|
|
26
|
-
return (
|
|
27
|
-
<TranscriptMarkdown
|
|
28
|
-
compact={roleKind === "assistant" || roleKind === "user"}
|
|
29
|
-
key={index}
|
|
30
|
-
text={block.code}
|
|
31
|
-
/>
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<div
|
|
37
|
-
className="min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5"
|
|
38
|
-
key={index}
|
|
39
|
-
>
|
|
40
|
-
<HighlightedCode code={block.code} language={block.language} />
|
|
41
|
-
</div>
|
|
42
|
-
);
|
|
43
|
-
})}
|
|
44
|
-
</div>
|
|
45
|
-
</MentionPeopleProvider>
|
|
46
|
-
);
|
|
47
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// Slack writes links as `<https://example.com>` or `<https://example.com|label>`.
|
|
2
|
-
const SLACK_LINK_RE = /<((?:https?|mailto):[^\s|<>]+)(?:\|([^<>]*))?>/g;
|
|
3
|
-
|
|
4
|
-
/** Reduce message Markdown to compact plain text for conversation previews. */
|
|
5
|
-
export function formatConversationActivityPreview(text: string): string {
|
|
6
|
-
return text
|
|
7
|
-
.replace(SLACK_LINK_RE, (_, url: string, label?: string) =>
|
|
8
|
-
label?.trim() ? label.trim() : url,
|
|
9
|
-
)
|
|
10
|
-
.replace(/\[([^\]]+)\]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
|
|
11
|
-
.replace(/(^|\s)([*_~`]{1,3})(?=\S)/g, "$1")
|
|
12
|
-
.replace(/([*_~`]{1,3})(?=\s|$|[.,!?;:])/g, "")
|
|
13
|
-
.replace(/^\s*(?:#{1,6}|[-+*]|\d+\.)\s+/gm, "")
|
|
14
|
-
.replace(/\s+/g, " ")
|
|
15
|
-
.trim();
|
|
16
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
-
import { Link } from "react-router";
|
|
3
|
-
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
4
|
-
|
|
5
|
-
import { peoplePath } from "../format";
|
|
6
|
-
import { cn } from "../styles";
|
|
7
|
-
import { TRANSCRIPT_ANCHOR_CLASS } from "./transcriptMarkdownLinks";
|
|
8
|
-
import { HighlightText } from "./transcriptSearch";
|
|
9
|
-
|
|
10
|
-
type TranscriptMention = {
|
|
11
|
-
end: number;
|
|
12
|
-
id: string;
|
|
13
|
-
label?: string;
|
|
14
|
-
sigil: "@" | "#";
|
|
15
|
-
start: number;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
// Matches the user and channel tokens that core keeps in message text.
|
|
19
|
-
const SLACK_MENTION_RE = /<([@#])([A-Z0-9]+)(?:\|([^<>]*))?>/g;
|
|
20
|
-
|
|
21
|
-
const TranscriptMentionPeople = createContext<
|
|
22
|
-
ReadonlyMap<string, ActorIdentity>
|
|
23
|
-
>(new Map());
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Share the people that the API resolved for one block of display text.
|
|
27
|
-
* Use `TranscriptText` or `MentionText`, which require the API mentions.
|
|
28
|
-
*/
|
|
29
|
-
export function MentionPeopleProvider(props: {
|
|
30
|
-
children: ReactNode;
|
|
31
|
-
mentions: readonly ActorIdentity[];
|
|
32
|
-
}) {
|
|
33
|
-
const people = new Map<string, ActorIdentity>();
|
|
34
|
-
for (const person of props.mentions) {
|
|
35
|
-
if (person.slackUserId) people.set(person.slackUserId, person);
|
|
36
|
-
}
|
|
37
|
-
return (
|
|
38
|
-
<TranscriptMentionPeople.Provider value={people}>
|
|
39
|
-
{props.children}
|
|
40
|
-
</TranscriptMentionPeople.Provider>
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Render one line of display text with the people the API resolved for it. */
|
|
45
|
-
export function MentionText(props: {
|
|
46
|
-
/**
|
|
47
|
-
* Link people to their page. Set false inside a button, which cannot hold
|
|
48
|
-
* a link.
|
|
49
|
-
*/
|
|
50
|
-
links?: boolean;
|
|
51
|
-
mentions: readonly ActorIdentity[];
|
|
52
|
-
text: string;
|
|
53
|
-
}) {
|
|
54
|
-
return (
|
|
55
|
-
<MentionPeopleProvider mentions={props.mentions}>
|
|
56
|
-
{renderMentionText(props.text, "mention-text", props.links)}
|
|
57
|
-
</MentionPeopleProvider>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Render text with Slack `<@U123>` and `<#C123|name>` tokens as readable
|
|
63
|
-
* mentions. It reads people from the nearest `MentionPeopleProvider`.
|
|
64
|
-
*/
|
|
65
|
-
export function renderMentionText(
|
|
66
|
-
text: string,
|
|
67
|
-
keyBase: string,
|
|
68
|
-
links = true,
|
|
69
|
-
): ReactNode[] {
|
|
70
|
-
const nodes: ReactNode[] = [];
|
|
71
|
-
let cursor = 0;
|
|
72
|
-
for (const mention of findMentions(text)) {
|
|
73
|
-
if (mention.start > cursor) {
|
|
74
|
-
nodes.push(
|
|
75
|
-
<HighlightText
|
|
76
|
-
key={`${keyBase}-m-${cursor}`}
|
|
77
|
-
text={text.slice(cursor, mention.start)}
|
|
78
|
-
/>,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
nodes.push(
|
|
82
|
-
<MentionView
|
|
83
|
-
key={`${keyBase}-m-${mention.start}`}
|
|
84
|
-
links={links}
|
|
85
|
-
mention={mention}
|
|
86
|
-
/>,
|
|
87
|
-
);
|
|
88
|
-
cursor = mention.end;
|
|
89
|
-
}
|
|
90
|
-
if (cursor < text.length) {
|
|
91
|
-
nodes.push(
|
|
92
|
-
<HighlightText
|
|
93
|
-
key={`${keyBase}-m-${cursor}`}
|
|
94
|
-
text={text.slice(cursor)}
|
|
95
|
-
/>,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
return nodes;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function findMentions(text: string): TranscriptMention[] {
|
|
102
|
-
return [...text.matchAll(SLACK_MENTION_RE)].map((match) => ({
|
|
103
|
-
end: match.index + match[0].length,
|
|
104
|
-
id: match[2]!,
|
|
105
|
-
label: match[3]?.trim() || undefined,
|
|
106
|
-
sigil: match[1] as "@" | "#",
|
|
107
|
-
start: match.index,
|
|
108
|
-
}));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function MentionView(props: { links: boolean; mention: TranscriptMention }) {
|
|
112
|
-
const people = useContext(TranscriptMentionPeople);
|
|
113
|
-
const { id, label, sigil } = props.mention;
|
|
114
|
-
const person = sigil === "@" ? people.get(id) : undefined;
|
|
115
|
-
const name = person?.fullName ?? person?.slackUserName ?? label ?? id;
|
|
116
|
-
const text = <HighlightText text={`${sigil}${name.replace(/^[@#]/, "")}`} />;
|
|
117
|
-
if (props.links && person?.email) {
|
|
118
|
-
return (
|
|
119
|
-
<Link
|
|
120
|
-
// Previews sit in cards that disable pointer events on their text.
|
|
121
|
-
className={cn("pointer-events-auto", TRANSCRIPT_ANCHOR_CLASS)}
|
|
122
|
-
title={id}
|
|
123
|
-
to={peoplePath(person.email)}
|
|
124
|
-
>
|
|
125
|
-
{text}
|
|
126
|
-
</Link>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
return (
|
|
130
|
-
<span className="font-medium text-dashboard-accent-text" title={id}>
|
|
131
|
-
{text}
|
|
132
|
-
</span>
|
|
133
|
-
);
|
|
134
|
-
}
|