@sentry/junior-dashboard 0.272.0 → 0.273.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 +69 -67
- package/dist/index.js +2 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +440 -430
- package/src/client/components/JuniorLogo.tsx +1 -1
- package/src/client/components/ProfileMenu.tsx +4 -41
- package/src/client/components/layout/DashboardChrome.tsx +9 -4
- package/src/client/components/layout/DashboardHeader.tsx +11 -51
- package/src/client/components/layout/NavRail.tsx +156 -0
- package/src/client/components/ui/sidebar.tsx +336 -0
- package/src/client/conversations/ConversationPage.tsx +3 -0
- package/src/client/conversations/PendingMailboxStack.tsx +22 -9
- package/src/client/conversations/TranscriptMessageShell.tsx +19 -8
- package/src/client/conversations/TranscriptMessageView.tsx +8 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
- package/src/client/conversations/conversationOutbox.ts +19 -0
- package/src/client/conversations/eventTranscript.ts +6 -0
- package/src/client/conversations/queries.ts +1 -17
- package/src/client/conversations/transcript.ts +12 -2
- package/src/client/format.ts +8 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +5 -1
- package/src/client/review-desk/queries.ts +4 -1
- package/src/client/types.ts +2 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
3
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { cn } from "@dashboard/lib/utils";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Tooltip,
|
|
9
|
+
TooltipContent,
|
|
10
|
+
TooltipTrigger,
|
|
11
|
+
} from "@dashboard/components/ui/tooltip";
|
|
12
|
+
|
|
13
|
+
const SIDEBAR_WIDTH = "16rem";
|
|
14
|
+
const SIDEBAR_WIDTH_ICON = "3rem";
|
|
15
|
+
|
|
16
|
+
type SidebarContextProps = {
|
|
17
|
+
state: "expanded" | "collapsed";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
|
21
|
+
|
|
22
|
+
function useSidebar() {
|
|
23
|
+
const context = React.useContext(SidebarContext);
|
|
24
|
+
if (!context) {
|
|
25
|
+
throw new Error("useSidebar must be used within a SidebarProvider.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return context;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Own the sidebar state and width variables for the page frame.
|
|
33
|
+
*
|
|
34
|
+
* The dashboard keeps only the desktop sidebar. It drops the stock toggle
|
|
35
|
+
* state, cookie, Cmd/Ctrl+B shortcut, and mobile sheet: the nav rail stays
|
|
36
|
+
* collapsed, and `DashboardHeader` owns mobile navigation.
|
|
37
|
+
*/
|
|
38
|
+
function SidebarProvider({
|
|
39
|
+
open = true,
|
|
40
|
+
className,
|
|
41
|
+
style,
|
|
42
|
+
children,
|
|
43
|
+
...props
|
|
44
|
+
}: React.ComponentProps<"div"> & {
|
|
45
|
+
open?: boolean;
|
|
46
|
+
}) {
|
|
47
|
+
// We add a state so that we can do data-state="expanded" or "collapsed".
|
|
48
|
+
// This makes it easier to style the sidebar with Tailwind classes.
|
|
49
|
+
const state = open ? "expanded" : "collapsed";
|
|
50
|
+
|
|
51
|
+
const contextValue = React.useMemo<SidebarContextProps>(
|
|
52
|
+
() => ({ state }),
|
|
53
|
+
[state],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<SidebarContext.Provider value={contextValue}>
|
|
58
|
+
<div
|
|
59
|
+
data-slot="sidebar-wrapper"
|
|
60
|
+
style={
|
|
61
|
+
{
|
|
62
|
+
"--sidebar-width": SIDEBAR_WIDTH,
|
|
63
|
+
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
64
|
+
...style,
|
|
65
|
+
} as React.CSSProperties
|
|
66
|
+
}
|
|
67
|
+
className={cn(
|
|
68
|
+
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
{...props}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
</SidebarContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Render the fixed sidebar from the `md` breakpoint up. */
|
|
80
|
+
function Sidebar({
|
|
81
|
+
side = "left",
|
|
82
|
+
variant = "sidebar",
|
|
83
|
+
collapsible = "offcanvas",
|
|
84
|
+
className,
|
|
85
|
+
children,
|
|
86
|
+
...props
|
|
87
|
+
}: React.ComponentProps<"div"> & {
|
|
88
|
+
side?: "left" | "right";
|
|
89
|
+
variant?: "sidebar" | "floating" | "inset";
|
|
90
|
+
collapsible?: "offcanvas" | "icon" | "none";
|
|
91
|
+
}) {
|
|
92
|
+
const { state } = useSidebar();
|
|
93
|
+
|
|
94
|
+
if (collapsible === "none") {
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
data-slot="sidebar"
|
|
98
|
+
className={cn(
|
|
99
|
+
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
|
100
|
+
className,
|
|
101
|
+
)}
|
|
102
|
+
{...props}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
className="group peer hidden text-sidebar-foreground md:block"
|
|
112
|
+
data-state={state}
|
|
113
|
+
data-collapsible={state === "collapsed" ? collapsible : ""}
|
|
114
|
+
data-variant={variant}
|
|
115
|
+
data-side={side}
|
|
116
|
+
data-slot="sidebar"
|
|
117
|
+
>
|
|
118
|
+
{/* This is what handles the sidebar gap on desktop */}
|
|
119
|
+
<div
|
|
120
|
+
data-slot="sidebar-gap"
|
|
121
|
+
className={cn(
|
|
122
|
+
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
|
123
|
+
"group-data-[collapsible=offcanvas]:w-0",
|
|
124
|
+
"group-data-[side=right]:rotate-180",
|
|
125
|
+
variant === "floating" || variant === "inset"
|
|
126
|
+
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
|
127
|
+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
|
128
|
+
)}
|
|
129
|
+
/>
|
|
130
|
+
<div
|
|
131
|
+
data-slot="sidebar-container"
|
|
132
|
+
data-side={side}
|
|
133
|
+
className={cn(
|
|
134
|
+
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
|
135
|
+
// Adjust the padding for floating and inset variants.
|
|
136
|
+
variant === "floating" || variant === "inset"
|
|
137
|
+
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
|
138
|
+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
|
139
|
+
className,
|
|
140
|
+
)}
|
|
141
|
+
{...props}
|
|
142
|
+
>
|
|
143
|
+
<div
|
|
144
|
+
data-sidebar="sidebar"
|
|
145
|
+
data-slot="sidebar-inner"
|
|
146
|
+
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
|
147
|
+
>
|
|
148
|
+
{children}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Hold content pinned to the top of the sidebar. */
|
|
156
|
+
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
data-slot="sidebar-header"
|
|
160
|
+
data-sidebar="header"
|
|
161
|
+
className={cn("flex flex-col gap-2 p-2", className)}
|
|
162
|
+
{...props}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Hold content pinned to the bottom of the sidebar. */
|
|
168
|
+
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
data-slot="sidebar-footer"
|
|
172
|
+
data-sidebar="footer"
|
|
173
|
+
className={cn("flex flex-col gap-2 p-2", className)}
|
|
174
|
+
{...props}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Scroll the sidebar body between the header and the footer. */
|
|
180
|
+
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
data-slot="sidebar-content"
|
|
184
|
+
data-sidebar="content"
|
|
185
|
+
className={cn(
|
|
186
|
+
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
|
187
|
+
className,
|
|
188
|
+
)}
|
|
189
|
+
{...props}
|
|
190
|
+
/>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Group related sidebar menus with shared padding. */
|
|
195
|
+
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
data-slot="sidebar-group"
|
|
199
|
+
data-sidebar="group"
|
|
200
|
+
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
|
201
|
+
{...props}
|
|
202
|
+
/>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** List sidebar menu items. */
|
|
207
|
+
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|
208
|
+
return (
|
|
209
|
+
<ul
|
|
210
|
+
data-slot="sidebar-menu"
|
|
211
|
+
data-sidebar="menu"
|
|
212
|
+
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
|
213
|
+
{...props}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Wrap one menu button and its badge. */
|
|
219
|
+
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
220
|
+
return (
|
|
221
|
+
<li
|
|
222
|
+
data-slot="sidebar-menu-item"
|
|
223
|
+
data-sidebar="menu-item"
|
|
224
|
+
className={cn("group/menu-item relative", className)}
|
|
225
|
+
{...props}
|
|
226
|
+
/>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sidebarMenuButtonVariants = cva(
|
|
231
|
+
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
|
232
|
+
{
|
|
233
|
+
variants: {
|
|
234
|
+
variant: {
|
|
235
|
+
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
236
|
+
outline:
|
|
237
|
+
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
|
238
|
+
},
|
|
239
|
+
size: {
|
|
240
|
+
default: "h-8 text-sm",
|
|
241
|
+
sm: "h-7 text-xs",
|
|
242
|
+
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
defaultVariants: {
|
|
246
|
+
variant: "default",
|
|
247
|
+
size: "default",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
/** Render a menu link or button, with a tooltip while the sidebar is collapsed. */
|
|
253
|
+
function SidebarMenuButton({
|
|
254
|
+
render,
|
|
255
|
+
isActive = false,
|
|
256
|
+
variant = "default",
|
|
257
|
+
size = "default",
|
|
258
|
+
tooltip,
|
|
259
|
+
className,
|
|
260
|
+
...props
|
|
261
|
+
}: useRender.ComponentProps<"button"> &
|
|
262
|
+
React.ComponentProps<"button"> & {
|
|
263
|
+
isActive?: boolean;
|
|
264
|
+
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
|
265
|
+
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
|
266
|
+
const { state } = useSidebar();
|
|
267
|
+
const comp = useRender({
|
|
268
|
+
defaultTagName: "button",
|
|
269
|
+
props: mergeProps<"button">(
|
|
270
|
+
{
|
|
271
|
+
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
|
272
|
+
},
|
|
273
|
+
props,
|
|
274
|
+
),
|
|
275
|
+
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
|
276
|
+
state: {
|
|
277
|
+
slot: "sidebar-menu-button",
|
|
278
|
+
sidebar: "menu-button",
|
|
279
|
+
size,
|
|
280
|
+
active: isActive,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (!tooltip) {
|
|
285
|
+
return comp;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (typeof tooltip === "string") {
|
|
289
|
+
tooltip = {
|
|
290
|
+
children: tooltip,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<Tooltip>
|
|
296
|
+
{comp}
|
|
297
|
+
<TooltipContent
|
|
298
|
+
side="right"
|
|
299
|
+
align="center"
|
|
300
|
+
hidden={state !== "collapsed"}
|
|
301
|
+
{...tooltip}
|
|
302
|
+
/>
|
|
303
|
+
</Tooltip>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Show a count beside a menu button. */
|
|
308
|
+
function SidebarMenuBadge({
|
|
309
|
+
className,
|
|
310
|
+
...props
|
|
311
|
+
}: React.ComponentProps<"div">) {
|
|
312
|
+
return (
|
|
313
|
+
<div
|
|
314
|
+
data-slot="sidebar-menu-badge"
|
|
315
|
+
data-sidebar="menu-badge"
|
|
316
|
+
className={cn(
|
|
317
|
+
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
|
318
|
+
className,
|
|
319
|
+
)}
|
|
320
|
+
{...props}
|
|
321
|
+
/>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export {
|
|
326
|
+
Sidebar,
|
|
327
|
+
SidebarContent,
|
|
328
|
+
SidebarFooter,
|
|
329
|
+
SidebarGroup,
|
|
330
|
+
SidebarHeader,
|
|
331
|
+
SidebarMenu,
|
|
332
|
+
SidebarMenuBadge,
|
|
333
|
+
SidebarMenuButton,
|
|
334
|
+
SidebarMenuItem,
|
|
335
|
+
SidebarProvider,
|
|
336
|
+
};
|
|
@@ -522,6 +522,7 @@ export function ConversationPage(props: {
|
|
|
522
522
|
// runs without interactive auth, so it has no approval to show.
|
|
523
523
|
view === "rich" && detail.data?.isParticipant && !agentTree ? (
|
|
524
524
|
<ConversationReplyFooter
|
|
525
|
+
authors={detail.data.authors}
|
|
525
526
|
conversationId={conversationId}
|
|
526
527
|
// Only pass committed ids for mailbox de-dupe. The full transcript is
|
|
527
528
|
// too large to re-enter the footer on every live poll while typing.
|
|
@@ -620,6 +621,7 @@ export function ConversationPage(props: {
|
|
|
620
621
|
* footer tree. Fast chat UIs isolate the composer the same way.
|
|
621
622
|
*/
|
|
622
623
|
const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
624
|
+
authors?: ConversationDetailReport["authors"];
|
|
623
625
|
committedMessageIds: readonly string[];
|
|
624
626
|
conversationId: string;
|
|
625
627
|
onPinRequest: () => void;
|
|
@@ -874,6 +876,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
874
876
|
>
|
|
875
877
|
<ConversationReviewBar conversationId={props.conversationId} />
|
|
876
878
|
<PendingMailboxStack
|
|
879
|
+
authors={props.authors}
|
|
877
880
|
cancelError={cancelError}
|
|
878
881
|
cancelPending={cancelPendingMessages.isPending}
|
|
879
882
|
cancelTargetInboundMessageId={cancelTargetInboundMessageId}
|
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
type ReactElement,
|
|
7
7
|
type ReactNode,
|
|
8
8
|
} from "react";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
INPUT_IMAGE_TYPES,
|
|
11
|
+
type ConversationDetailReport,
|
|
12
|
+
} from "@sentry/junior/api/schema";
|
|
10
13
|
import {
|
|
11
14
|
AlertCircle,
|
|
12
15
|
Clock3,
|
|
@@ -230,14 +233,17 @@ function PendingMetaIcons(props: {
|
|
|
230
233
|
);
|
|
231
234
|
}
|
|
232
235
|
|
|
233
|
-
function PendingAuthorAvatar(props: {
|
|
236
|
+
function PendingAuthorAvatar(props: {
|
|
237
|
+
authors?: ConversationDetailReport["authors"];
|
|
238
|
+
message: ConversationMailboxMessage;
|
|
239
|
+
}) {
|
|
240
|
+
const { actorIdentityId } = props.message;
|
|
234
241
|
// Pending rows can come from web or Slack. Redacted rows hide the author.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return
|
|
239
|
-
|
|
240
|
-
);
|
|
242
|
+
const author =
|
|
243
|
+
(actorIdentityId ? props.authors?.[actorIdentityId] : undefined) ??
|
|
244
|
+
props.message.actorIdentity;
|
|
245
|
+
if (!author) return <ActorAvatar name="?" size="list" />;
|
|
246
|
+
return <ParticipantAvatar participant={author} size="list" />;
|
|
241
247
|
}
|
|
242
248
|
|
|
243
249
|
/** Show small previews so the reader can tell which images a queued message has. */
|
|
@@ -294,6 +300,7 @@ function PendingAttachmentThumbnails(props: {
|
|
|
294
300
|
|
|
295
301
|
function PendingRow(props: {
|
|
296
302
|
actionsDisabled: boolean;
|
|
303
|
+
authors?: ConversationDetailReport["authors"];
|
|
297
304
|
cancelError: boolean;
|
|
298
305
|
cancelPending: boolean;
|
|
299
306
|
canEdit: boolean;
|
|
@@ -322,7 +329,10 @@ function PendingRow(props: {
|
|
|
322
329
|
className="items-center"
|
|
323
330
|
left={
|
|
324
331
|
<>
|
|
325
|
-
<PendingAuthorAvatar
|
|
332
|
+
<PendingAuthorAvatar
|
|
333
|
+
authors={props.authors}
|
|
334
|
+
message={props.message}
|
|
335
|
+
/>
|
|
326
336
|
{redacted ? null : (
|
|
327
337
|
<PendingAttachmentThumbnails
|
|
328
338
|
conversationId={props.conversationId}
|
|
@@ -438,6 +448,8 @@ function sameEmail(left: string | undefined, right: string | undefined) {
|
|
|
438
448
|
|
|
439
449
|
/** Render accepted mailbox rows as a compact stack attached above the composer. */
|
|
440
450
|
export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
|
|
451
|
+
/** Author of each row, by `actorIdentityId`. */
|
|
452
|
+
authors?: ConversationDetailReport["authors"];
|
|
441
453
|
cancelError?: boolean;
|
|
442
454
|
cancelPending?: boolean;
|
|
443
455
|
cancelTargetInboundMessageId?: string;
|
|
@@ -506,6 +518,7 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
|
|
|
506
518
|
return (
|
|
507
519
|
<PendingRow
|
|
508
520
|
actionsDisabled={actionsDisabled}
|
|
521
|
+
authors={props.authors}
|
|
509
522
|
cancelError={Boolean(props.cancelError && isTarget)}
|
|
510
523
|
cancelPending={Boolean(props.cancelPending && isTarget)}
|
|
511
524
|
canEdit={ownWebRow}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { ClipboardEventHandler, ReactNode } from "react";
|
|
2
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
|
2
3
|
import { transcriptRoleKind } from "../format";
|
|
3
4
|
import { cn } from "../styles";
|
|
4
5
|
|
|
5
6
|
/** Align message text and attachments beside their avatars. */
|
|
6
7
|
export function TranscriptMessageShell(props: {
|
|
7
8
|
actor: string;
|
|
9
|
+
/** Junior API path of the author photo. Initials show without it. */
|
|
10
|
+
avatarUrl?: string;
|
|
8
11
|
children: ReactNode;
|
|
9
12
|
onCopy?: ClipboardEventHandler<HTMLElement>;
|
|
10
13
|
role: string;
|
|
@@ -36,14 +39,22 @@ export function TranscriptMessageShell(props: {
|
|
|
36
39
|
src="/_junior/dashboard/avatar.png"
|
|
37
40
|
/>
|
|
38
41
|
) : (
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
<Avatar className="size-8 border-0 bg-violet-300 shadow-none">
|
|
43
|
+
<AvatarImage
|
|
44
|
+
alt=""
|
|
45
|
+
loading="lazy"
|
|
46
|
+
referrerPolicy="no-referrer"
|
|
47
|
+
src={props.avatarUrl}
|
|
48
|
+
/>
|
|
49
|
+
<AvatarFallback>
|
|
50
|
+
{props.actor
|
|
51
|
+
.split(/\s+/)
|
|
52
|
+
.map((word) => word[0])
|
|
53
|
+
.slice(0, 2)
|
|
54
|
+
.join("")
|
|
55
|
+
.toUpperCase()}
|
|
56
|
+
</AvatarFallback>
|
|
57
|
+
</Avatar>
|
|
47
58
|
)}
|
|
48
59
|
</div>
|
|
49
60
|
) : null}
|
|
@@ -40,6 +40,9 @@ export const TranscriptMessageView = memo(
|
|
|
40
40
|
<TranscriptMessageShell
|
|
41
41
|
role={props.message.role}
|
|
42
42
|
actor={transcriptMessageActorLabel(props.conversation, props.message)}
|
|
43
|
+
avatarUrl={
|
|
44
|
+
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
|
|
45
|
+
}
|
|
43
46
|
onCopy={(event) => {
|
|
44
47
|
const selection = event.currentTarget.ownerDocument.getSelection();
|
|
45
48
|
if (
|
|
@@ -97,7 +100,8 @@ export const TranscriptMessageView = memo(
|
|
|
97
100
|
previous.conversation.conversationId === next.conversation.conversationId &&
|
|
98
101
|
previous.conversation.surface === next.conversation.surface &&
|
|
99
102
|
previous.conversation.canFork === next.conversation.canFork &&
|
|
100
|
-
previous.conversation.actorIdentity === next.conversation.actorIdentity
|
|
103
|
+
previous.conversation.actorIdentity === next.conversation.actorIdentity &&
|
|
104
|
+
previous.conversation.authors === next.conversation.authors,
|
|
101
105
|
);
|
|
102
106
|
|
|
103
107
|
/** Render a redacted primary chat message without exposing body content. */
|
|
@@ -109,6 +113,9 @@ export function RedactedMessageView(props: {
|
|
|
109
113
|
<TranscriptMessageShell
|
|
110
114
|
role={props.message.role}
|
|
111
115
|
actor={transcriptMessageActorLabel(props.conversation, props.message)}
|
|
116
|
+
avatarUrl={
|
|
117
|
+
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
|
|
118
|
+
}
|
|
112
119
|
>
|
|
113
120
|
<TranscriptMessageHeader
|
|
114
121
|
message={props.message}
|
|
@@ -203,7 +203,7 @@ function TurnContextActor(props: { actor: ActorIdentity; timestamp?: number }) {
|
|
|
203
203
|
aria-label="Speaker"
|
|
204
204
|
className="flex min-w-0 items-center gap-3 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
|
|
205
205
|
>
|
|
206
|
-
<ActorAvatar name={name} size="detail" />
|
|
206
|
+
<ActorAvatar imageUrl={props.actor.avatarUrl} name={name} size="detail" />
|
|
207
207
|
<div className="min-w-0 flex-1">
|
|
208
208
|
<div className="flex min-w-0 items-baseline justify-between gap-3">
|
|
209
209
|
{email ? (
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
1
2
|
import type {
|
|
2
3
|
ActorIdentity,
|
|
3
4
|
ConversationPendingMessage,
|
|
4
5
|
InputImage,
|
|
5
6
|
} from "@sentry/junior/api/schema";
|
|
7
|
+
import type { DashboardCoreData } from "../types";
|
|
6
8
|
|
|
7
9
|
/** Client-owned mailbox row waiting on accept, server visibility, or retry. */
|
|
8
10
|
export type ConversationOutboxMessage = {
|
|
@@ -27,6 +29,22 @@ export type ConversationMailboxMessage = ConversationPendingMessage & {
|
|
|
27
29
|
idempotencyKey?: string;
|
|
28
30
|
};
|
|
29
31
|
|
|
32
|
+
/** Identity of the viewer, for the outbox rows that they send. */
|
|
33
|
+
export function outboxActorIdentity(
|
|
34
|
+
queryClient: QueryClient,
|
|
35
|
+
): ActorIdentity | undefined {
|
|
36
|
+
const user = queryClient.getQueryData<DashboardCoreData>([
|
|
37
|
+
"dashboard",
|
|
38
|
+
"core",
|
|
39
|
+
])?.me.user;
|
|
40
|
+
if (!user?.email) return undefined;
|
|
41
|
+
return {
|
|
42
|
+
...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : undefined),
|
|
43
|
+
email: user.email,
|
|
44
|
+
fullName: user.name || undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
/** Stable React Query key for one conversation's local send outbox. */
|
|
31
49
|
export function conversationOutboxQueryKey(conversationId: string | undefined) {
|
|
32
50
|
return ["conversation", conversationId, "outbox"] as const;
|
|
@@ -163,6 +181,7 @@ function sameActorIdentity(
|
|
|
163
181
|
if (left === right) return true;
|
|
164
182
|
if (!left || !right) return false;
|
|
165
183
|
return (
|
|
184
|
+
left.avatarUrl === right.avatarUrl &&
|
|
166
185
|
left.email === right.email &&
|
|
167
186
|
left.fullName === right.fullName &&
|
|
168
187
|
left.slackUserId === right.slackUserId &&
|
|
@@ -93,6 +93,9 @@ export function pendingTranscriptMessage(
|
|
|
93
93
|
...(message.actorIdentity
|
|
94
94
|
? { actorIdentity: message.actorIdentity }
|
|
95
95
|
: undefined),
|
|
96
|
+
...(message.actorIdentityId
|
|
97
|
+
? { actorIdentityId: message.actorIdentityId }
|
|
98
|
+
: undefined),
|
|
96
99
|
...(message.mentions ? { mentions: message.mentions } : undefined),
|
|
97
100
|
};
|
|
98
101
|
}
|
|
@@ -222,6 +225,9 @@ export function transcriptMessagesFromEvents(
|
|
|
222
225
|
...(data.actorIdentity
|
|
223
226
|
? { actorIdentity: data.actorIdentity }
|
|
224
227
|
: undefined),
|
|
228
|
+
...(data.actorIdentityId
|
|
229
|
+
? { actorIdentityId: data.actorIdentityId }
|
|
230
|
+
: undefined),
|
|
225
231
|
...(data.agentInvocationId
|
|
226
232
|
? { agentInvocationId: data.agentInvocationId }
|
|
227
233
|
: undefined),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useDeferredValue, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
queryOptions,
|
|
4
|
-
type QueryClient,
|
|
5
4
|
useInfiniteQuery,
|
|
6
5
|
useMutation,
|
|
7
6
|
useMutationState,
|
|
@@ -9,7 +8,6 @@ import {
|
|
|
9
8
|
useQueryClient,
|
|
10
9
|
} from "@tanstack/react-query";
|
|
11
10
|
import type {
|
|
12
|
-
ActorIdentity,
|
|
13
11
|
ConversationDetailReport,
|
|
14
12
|
ConversationEventPage,
|
|
15
13
|
ConversationFeed,
|
|
@@ -30,7 +28,6 @@ import {
|
|
|
30
28
|
stopConversationTurnResponseSchema,
|
|
31
29
|
} from "@sentry/junior/api/schema";
|
|
32
30
|
|
|
33
|
-
import type { DashboardCoreData } from "../types";
|
|
34
31
|
import {
|
|
35
32
|
DashboardApiError,
|
|
36
33
|
del,
|
|
@@ -45,6 +42,7 @@ import {
|
|
|
45
42
|
failConversationOutboxMessage,
|
|
46
43
|
isInlineOutboxMessage,
|
|
47
44
|
mergeConversationMailboxMessages,
|
|
45
|
+
outboxActorIdentity,
|
|
48
46
|
acceptConversationOutboxMessage,
|
|
49
47
|
reuseConversationMailboxMessages,
|
|
50
48
|
splitInlineMailboxMessages,
|
|
@@ -213,20 +211,6 @@ function keepLatestReadPosition(
|
|
|
213
211
|
return { ...next, readPosition: kept };
|
|
214
212
|
}
|
|
215
213
|
|
|
216
|
-
function outboxActorIdentity(
|
|
217
|
-
queryClient: QueryClient,
|
|
218
|
-
): ActorIdentity | undefined {
|
|
219
|
-
const user = queryClient.getQueryData<DashboardCoreData>([
|
|
220
|
-
"dashboard",
|
|
221
|
-
"core",
|
|
222
|
-
])?.me.user;
|
|
223
|
-
if (!user?.email) return undefined;
|
|
224
|
-
return {
|
|
225
|
-
email: user.email,
|
|
226
|
-
fullName: user.name || undefined,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
214
|
/** Create one dashboard conversation and refresh the personal feed. */
|
|
231
215
|
export function useCreateConversation() {
|
|
232
216
|
const queryClient = useQueryClient();
|
|
@@ -223,8 +223,18 @@ export function reuseConversationEventReferences(
|
|
|
223
223
|
JSON.stringify(previous.annotations) === JSON.stringify(next.annotations)
|
|
224
224
|
? previous.annotations
|
|
225
225
|
: next.annotations;
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
const authors =
|
|
227
|
+
JSON.stringify(previous.authors) === JSON.stringify(next.authors)
|
|
228
|
+
? previous.authors
|
|
229
|
+
: next.authors;
|
|
230
|
+
if (
|
|
231
|
+
events === next.events &&
|
|
232
|
+
annotations === next.annotations &&
|
|
233
|
+
authors === next.authors
|
|
234
|
+
) {
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
return { ...next, events, annotations, authors };
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
function sameConversationEventVersion(
|
package/src/client/format.ts
CHANGED
|
@@ -354,6 +354,10 @@ export function transcriptMessageActor(
|
|
|
354
354
|
): ActorIdentity | undefined {
|
|
355
355
|
if (transcriptRoleKind(message.role) !== "user") return undefined;
|
|
356
356
|
if (message.agentInvocationId) return undefined;
|
|
357
|
+
const author = message.actorIdentityId
|
|
358
|
+
? conversation.authors?.[message.actorIdentityId]
|
|
359
|
+
: undefined;
|
|
360
|
+
if (author) return author;
|
|
357
361
|
if (actorLabel(message.actorIdentity)) return message.actorIdentity;
|
|
358
362
|
if (actorLabel(conversation.actorIdentity)) return conversation.actorIdentity;
|
|
359
363
|
return undefined;
|
|
@@ -370,7 +374,10 @@ export function transcriptMessageActorLabel(
|
|
|
370
374
|
// The conversation actor started the child agent but did not write its
|
|
371
375
|
// input, so do not fall back to that actor here.
|
|
372
376
|
if (message.agentInvocationId) return "Agent invocation";
|
|
373
|
-
|
|
377
|
+
// Show the person's name, not their email. A web send carries both, and
|
|
378
|
+
// its durable Message carries only the name.
|
|
379
|
+
const actor = transcriptMessageActor(conversation, message);
|
|
380
|
+
return actor?.fullName?.trim() || (actorLabel(actor) ?? "User");
|
|
374
381
|
}
|
|
375
382
|
if (kind === "system") return "System";
|
|
376
383
|
if (kind === "tool") return "Tool";
|