@sentry/junior-dashboard 0.269.0 → 0.270.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 +14 -4
- package/src/client/components/AutomationCard.tsx +2 -1
- package/src/client/components/UserInputForm.tsx +109 -36
- package/src/client/conversations/AGENTS.md +0 -4
- 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 +93 -32
- 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/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/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/review-desk/ConversationReviewBar.tsx +0 -1
- package/src/client/review-desk/ReviewItemActions.tsx +6 -2
- package/src/client/review-desk/ReviewItemCard.tsx +58 -17
- package/src/client/review-desk/ReviewItemDetail.tsx +2 -2
- package/src/client/review-desk/UnsentAnswers.tsx +148 -0
- package/src/client/review-desk/reviewItemFormat.ts +29 -1
- package/src/client/conversations/TranscriptText.tsx +0 -47
- package/src/client/conversations/conversationActivityPreview.ts +0 -16
- package/src/client/conversations/transcriptMentions.tsx +0 -134
|
@@ -1,38 +1,79 @@
|
|
|
1
|
+
import { ArrowRight, Inbox } from "lucide-react";
|
|
1
2
|
import { Link } from "react-router";
|
|
2
3
|
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
3
4
|
|
|
4
5
|
import { StatusChip } from "../components/StatusChip";
|
|
5
|
-
import {
|
|
6
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
7
|
+
import { cn } from "../styles";
|
|
6
8
|
import { ReviewItemActions } from "./ReviewItemActions";
|
|
7
9
|
import { useReviewItemData } from "./queries";
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
assigneeLabel,
|
|
12
|
+
reviewItemPath,
|
|
13
|
+
reviewItemStatusLabel,
|
|
14
|
+
} from "./reviewItemFormat";
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Show one Review item as a card: its title, status, description, and the
|
|
12
|
-
* viewer's actions, with a link to the review desk.
|
|
18
|
+
* viewer's actions, with a link to the review desk. The card uses the same
|
|
19
|
+
* frame as transcript object cards, so it reads as one record, not as prose.
|
|
13
20
|
*/
|
|
14
21
|
export function ReviewItemCard(props: { item: ReviewItemPayload }) {
|
|
15
22
|
const { item } = props;
|
|
23
|
+
const open = item.status === "open";
|
|
24
|
+
const assigned = item.assignees.length > 0 || item.assignedToEveryone;
|
|
16
25
|
return (
|
|
17
26
|
<section
|
|
18
27
|
aria-label={`Review item: ${item.title}`}
|
|
19
|
-
className="
|
|
28
|
+
className="my-1 w-full min-w-0 max-w-[37.5rem] overflow-hidden rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-panel font-sans text-sm text-dashboard-text"
|
|
20
29
|
>
|
|
21
|
-
<header className="flex
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
<header className="flex items-start gap-3 px-4 pb-3 pt-4 md:px-5">
|
|
31
|
+
{/* The accent tile marks an item that still needs a person. */}
|
|
32
|
+
<span
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
className={cn(
|
|
35
|
+
"grid size-9 shrink-0 place-items-center rounded-lg border",
|
|
36
|
+
open
|
|
37
|
+
? "border-dashboard-accent/30 bg-dashboard-accent/10 text-dashboard-accent"
|
|
38
|
+
: "border-dashboard-border bg-dashboard-fill-soft text-dashboard-text-muted",
|
|
39
|
+
)}
|
|
27
40
|
>
|
|
28
|
-
{
|
|
29
|
-
</
|
|
41
|
+
<Inbox size={18} />
|
|
42
|
+
</span>
|
|
43
|
+
<div className="grid min-w-0 flex-1 gap-1">
|
|
44
|
+
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
45
|
+
<p className="m-0 text-xs text-dashboard-text-muted">Review item</p>
|
|
46
|
+
<StatusChip
|
|
47
|
+
className="shrink-0"
|
|
48
|
+
size="compact"
|
|
49
|
+
tone={open ? "accent" : "neutral"}
|
|
50
|
+
>
|
|
51
|
+
{reviewItemStatusLabel(item)}
|
|
52
|
+
</StatusChip>
|
|
53
|
+
</div>
|
|
54
|
+
<h3 className="m-0 break-words text-base font-semibold leading-snug">
|
|
55
|
+
{item.title}
|
|
56
|
+
</h3>
|
|
57
|
+
</div>
|
|
30
58
|
</header>
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
<div className="grid min-w-0 gap-4 px-4 pb-4 md:px-5">
|
|
60
|
+
<DisplayText text={item.body} />
|
|
61
|
+
<div className="border-t border-dashboard-border pt-4">
|
|
62
|
+
<ReviewItemActions item={item} />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-dashboard-border bg-dashboard-fill-faint px-4 py-3 text-xs md:px-5">
|
|
66
|
+
<Link
|
|
67
|
+
className="inline-flex items-center gap-2 font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus"
|
|
68
|
+
to={reviewItemPath(item.id)}
|
|
69
|
+
>
|
|
70
|
+
Open on the review desk
|
|
71
|
+
<ArrowRight aria-hidden="true" size={14} />
|
|
72
|
+
</Link>
|
|
73
|
+
<span className="min-w-0 truncate text-dashboard-text-muted">
|
|
74
|
+
{assigned ? `Assigned to ${assigneeLabel(item)}` : "Unassigned"}
|
|
75
|
+
</span>
|
|
76
|
+
</footer>
|
|
36
77
|
</section>
|
|
37
78
|
);
|
|
38
79
|
}
|
|
@@ -4,7 +4,7 @@ import { Link } from "react-router";
|
|
|
4
4
|
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
5
5
|
|
|
6
6
|
import { StatusChip } from "../components/StatusChip";
|
|
7
|
-
import {
|
|
7
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
8
8
|
import { conversationPath } from "../conversations/conversationRoutes";
|
|
9
9
|
import { automationPath, formatRelativeTime } from "../format";
|
|
10
10
|
import { ReviewItemActions } from "./ReviewItemActions";
|
|
@@ -54,7 +54,7 @@ export function ReviewItemDetail(props: { item: ReviewItemPayload }) {
|
|
|
54
54
|
{item.visibility === "private" ? " · Private" : null}
|
|
55
55
|
</p>
|
|
56
56
|
</header>
|
|
57
|
-
<
|
|
57
|
+
<DisplayText text={item.body} />
|
|
58
58
|
<section
|
|
59
59
|
aria-label={item.status === "open" ? "Actions" : "Outcome"}
|
|
60
60
|
className="border-t border-dashboard-border-subtle pt-5"
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
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 {
|
|
@@ -59,3 +62,28 @@ export function reviewItemIdFromToolCall(call: {
|
|
|
59
62
|
if (toolName !== ADD_REVIEW_ITEM_TOOL_NAME) return undefined;
|
|
60
63
|
return addedItemId(call.output);
|
|
61
64
|
}
|
|
65
|
+
|
|
66
|
+
/** Return the id of the last Review item that a tool call in the events added. */
|
|
67
|
+
export function latestReviewItemId(
|
|
68
|
+
events: readonly ConversationReportEvent[] | undefined,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
for (let index = (events?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
71
|
+
const data = events![index]!.data;
|
|
72
|
+
if (data.type !== "tool_calls") continue;
|
|
73
|
+
for (let call = data.calls.length - 1; call >= 0; call -= 1) {
|
|
74
|
+
const id = reviewItemIdFromToolCall(data.calls[call]!);
|
|
75
|
+
if (id) return id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when an open item asks a question that the viewer can answer. */
|
|
82
|
+
export function reviewItemWaitsForAnswer(item: ReviewItemPayload): boolean {
|
|
83
|
+
return (
|
|
84
|
+
item.status === "open" &&
|
|
85
|
+
item.kind === "action" &&
|
|
86
|
+
Boolean(item.request) &&
|
|
87
|
+
item.canAct
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -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
|
-
}
|