@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.
Files changed (55) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +78 -75
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/client/App.tsx +14 -4
  7. package/src/client/components/AutomationCard.tsx +2 -1
  8. package/src/client/components/UserInputForm.tsx +109 -36
  9. package/src/client/conversations/AGENTS.md +0 -4
  10. package/src/client/conversations/ConversationBrief.tsx +55 -36
  11. package/src/client/conversations/ConversationComposer.tsx +27 -8
  12. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  13. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  14. package/src/client/conversations/ConversationPage.tsx +93 -32
  15. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  16. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  17. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  18. package/src/client/conversations/ConversationWatches.tsx +4 -1
  19. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  20. package/src/client/conversations/EventDetails.tsx +6 -9
  21. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  22. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  23. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  24. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  25. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  26. package/src/client/conversations/TranscriptRows.tsx +6 -1
  27. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  28. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  29. package/src/client/conversations/TranscriptView.tsx +6 -6
  30. package/src/client/conversations/conversationOutbox.ts +13 -3
  31. package/src/client/conversations/conversationSections.ts +36 -8
  32. package/src/client/conversations/eventTranscript.ts +1 -0
  33. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  34. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  35. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  36. package/src/client/display-text/DisplayText.tsx +68 -0
  37. package/src/client/display-text/emoji.ts +54 -0
  38. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  39. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  40. package/src/client/display-text/mentions.tsx +148 -0
  41. package/src/client/display-text/plainText.ts +29 -0
  42. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  43. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  44. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  45. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  46. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  47. package/src/client/review-desk/ConversationReviewBar.tsx +0 -1
  48. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  49. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  50. package/src/client/review-desk/ReviewItemDetail.tsx +2 -2
  51. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  52. package/src/client/review-desk/reviewItemFormat.ts +29 -1
  53. package/src/client/conversations/TranscriptText.tsx +0 -47
  54. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  55. 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 { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
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 { reviewItemPath, reviewItemStatusLabel } from "./reviewItemFormat";
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="grid gap-3 rounded-lg border border-violet-300/25 bg-violet-300/[0.06] p-3 font-sans text-sm text-dashboard-text"
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 min-w-0 items-baseline justify-between gap-3">
22
- <span className="min-w-0 truncate font-medium">{item.title}</span>
23
- <StatusChip
24
- className="shrink-0"
25
- size="compact"
26
- tone={item.status === "open" ? "accent" : "neutral"}
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
- {reviewItemStatusLabel(item)}
29
- </StatusChip>
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
- <TranscriptMarkdown text={item.body} />
32
- <ReviewItemActions item={item} />
33
- <Link className="w-fit text-xs" to={reviewItemPath(item.id)}>
34
- Open on the review desk
35
- </Link>
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 { TranscriptMarkdown } from "../conversations/TranscriptMarkdown";
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
- <TranscriptMarkdown text={item.body} />
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 { ReviewItemPayload } from "@sentry/junior/api/schema";
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
- }