@sentry/junior-dashboard 0.271.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 +44 -3
- package/dist/client.js +69 -67
- package/dist/index.js +40 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/app.ts +2 -0
- 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 +14 -0
- package/src/client/conversations/ConversationTranscript.tsx +74 -5
- 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/TranscriptRows.tsx +23 -0
- package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
- package/src/client/conversations/TranscriptView.tsx +22 -0
- package/src/client/conversations/conversationOutbox.ts +19 -0
- package/src/client/conversations/eventTranscript.ts +6 -0
- package/src/client/conversations/queries.ts +29 -20
- package/src/client/conversations/transcript.ts +12 -2
- package/src/client/conversations/transcriptBottomPinning.ts +84 -13
- package/src/client/conversations/transcriptReadPosition.ts +267 -0
- package/src/client/conversations/transcriptRenderModel.ts +16 -2
- package/src/client/format.ts +8 -1
- package/src/client/pages/automations/AutomationActions.tsx +2 -3
- package/src/client/pages/automations/AutomationEditor.tsx +18 -30
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
- package/src/client/pages/settings/SettingsPage.tsx +62 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +26 -40
- package/src/client/review-desk/ReviewDeskSidebar.tsx +29 -23
- package/src/client/review-desk/ReviewItemActions.tsx +1 -10
- package/src/client/review-desk/queries.ts +6 -5
- package/src/client/review-desk/reviewItemFormat.ts +3 -5
- package/src/client/types.ts +2 -0
- package/src/mock-reporting/read-positions.ts +39 -0
- package/src/mock-reporting/routes.ts +26 -1
- package/src/tailwind.css +1 -1
- package/src/client/components/AGENTS.md +0 -36
- package/src/client/conversations/AGENTS.md +0 -17
- package/src/client/pages/automations/AGENTS.md +0 -15
|
@@ -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
|
+
};
|
|
@@ -98,6 +98,11 @@ import {
|
|
|
98
98
|
} from "../review-desk/reviewItemFormat";
|
|
99
99
|
import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
|
|
100
100
|
import { Transcript } from "./TranscriptView";
|
|
101
|
+
import {
|
|
102
|
+
readConversationStartPosition,
|
|
103
|
+
useMarkConversationRead,
|
|
104
|
+
useReadPositionAtOpen,
|
|
105
|
+
} from "./transcriptReadPosition";
|
|
101
106
|
import { TranscriptLoading } from "./TranscriptLoading";
|
|
102
107
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
103
108
|
import {
|
|
@@ -166,6 +171,8 @@ export function ConversationPage(props: {
|
|
|
166
171
|
const conversations = buildConversations(summaries);
|
|
167
172
|
const detail = useConversationData(conversationId);
|
|
168
173
|
const archive = useArchiveConversation(conversationId);
|
|
174
|
+
const markRead = useMarkConversationRead(conversationId);
|
|
175
|
+
const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
|
|
169
176
|
const feedConversation = conversations.find(
|
|
170
177
|
(item) => item.id === conversationId,
|
|
171
178
|
);
|
|
@@ -484,9 +491,11 @@ export function ConversationPage(props: {
|
|
|
484
491
|
hasPreviousPage={detail.hasPreviousPage}
|
|
485
492
|
historyError={detail.historyError}
|
|
486
493
|
historyVersion={detail.historyVersion}
|
|
494
|
+
lastReadSeq={readAtOpen.lastReadSeq}
|
|
487
495
|
live={live}
|
|
488
496
|
loadingPreviousPage={detail.isLoadingPreviousPage}
|
|
489
497
|
onLoadPreviousPage={detail.loadPreviousPage}
|
|
498
|
+
onRead={markRead}
|
|
490
499
|
onSelectedEventSeqChange={setSelectedEventSeq}
|
|
491
500
|
pendingMessages={detail.inlineMessages}
|
|
492
501
|
pinRequestVersion={pinRequestVersion}
|
|
@@ -497,8 +506,10 @@ export function ConversationPage(props: {
|
|
|
497
506
|
search={search}
|
|
498
507
|
onSearchChange={setSearch}
|
|
499
508
|
selectedEventSeq={selectedEventSeq}
|
|
509
|
+
startAtUnread={readConversationStartPosition() === "unread"}
|
|
500
510
|
transcript={transcript}
|
|
501
511
|
turnContextPanel={turnContextPanel}
|
|
512
|
+
unread={readAtOpen.unread}
|
|
502
513
|
view={view}
|
|
503
514
|
/>
|
|
504
515
|
</>
|
|
@@ -511,6 +522,7 @@ export function ConversationPage(props: {
|
|
|
511
522
|
// runs without interactive auth, so it has no approval to show.
|
|
512
523
|
view === "rich" && detail.data?.isParticipant && !agentTree ? (
|
|
513
524
|
<ConversationReplyFooter
|
|
525
|
+
authors={detail.data.authors}
|
|
514
526
|
conversationId={conversationId}
|
|
515
527
|
// Only pass committed ids for mailbox de-dupe. The full transcript is
|
|
516
528
|
// too large to re-enter the footer on every live poll while typing.
|
|
@@ -609,6 +621,7 @@ export function ConversationPage(props: {
|
|
|
609
621
|
* footer tree. Fast chat UIs isolate the composer the same way.
|
|
610
622
|
*/
|
|
611
623
|
const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
624
|
+
authors?: ConversationDetailReport["authors"];
|
|
612
625
|
committedMessageIds: readonly string[];
|
|
613
626
|
conversationId: string;
|
|
614
627
|
onPinRequest: () => void;
|
|
@@ -863,6 +876,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
863
876
|
>
|
|
864
877
|
<ConversationReviewBar conversationId={props.conversationId} />
|
|
865
878
|
<PendingMailboxStack
|
|
879
|
+
authors={props.authors}
|
|
866
880
|
cancelError={cancelError}
|
|
867
881
|
cancelPending={cancelPendingMessages.isPending}
|
|
868
882
|
cancelTargetInboundMessageId={cancelTargetInboundMessageId}
|
|
@@ -49,8 +49,13 @@ import {
|
|
|
49
49
|
type RenderedTranscriptEntry,
|
|
50
50
|
} from "./transcriptRenderModel";
|
|
51
51
|
import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
TranscriptRow,
|
|
54
|
+
TranscriptRows,
|
|
55
|
+
TranscriptUnreadMarker,
|
|
56
|
+
} from "./TranscriptRows";
|
|
53
57
|
import { TranscriptTimestampProvider } from "./TranscriptTimestamp";
|
|
58
|
+
import type { TranscriptUnreadRange } from "./transcriptReadPosition";
|
|
54
59
|
|
|
55
60
|
type TranscriptEntry = ReturnType<typeof groupTranscriptMessages>[number];
|
|
56
61
|
type TranscriptContextEntry = Extract<TranscriptEntry, { kind: "context" }>;
|
|
@@ -85,6 +90,8 @@ export const ConversationTranscriptView = memo(
|
|
|
85
90
|
/** Sends that history does not have yet. History wins on message id. */
|
|
86
91
|
pendingMessages?: readonly ConversationMailboxMessage[];
|
|
87
92
|
responding?: boolean;
|
|
93
|
+
/** Show the "New" marker before the first row in this range. */
|
|
94
|
+
unread?: TranscriptUnreadRange;
|
|
88
95
|
}) {
|
|
89
96
|
// Cards can change while their Message events stay unchanged.
|
|
90
97
|
const { events, annotations } = props.conversation;
|
|
@@ -106,6 +113,7 @@ export const ConversationTranscriptView = memo(
|
|
|
106
113
|
conversation={props.conversation}
|
|
107
114
|
messages={messages}
|
|
108
115
|
responding={props.responding}
|
|
116
|
+
unread={props.unread}
|
|
109
117
|
/>
|
|
110
118
|
</section>
|
|
111
119
|
</TranscriptTimestampProvider>
|
|
@@ -121,6 +129,7 @@ function SegmentEvents(props: {
|
|
|
121
129
|
conversation: ConversationTranscript;
|
|
122
130
|
messages: TranscriptViewMessage[];
|
|
123
131
|
responding?: boolean;
|
|
132
|
+
unread?: TranscriptUnreadRange;
|
|
124
133
|
}) {
|
|
125
134
|
return (
|
|
126
135
|
<TranscriptRows>
|
|
@@ -134,6 +143,7 @@ function SegmentEvents(props: {
|
|
|
134
143
|
onOpenSubagentTranscript={props.onOpenSubagentTranscript}
|
|
135
144
|
transcript={props.messages}
|
|
136
145
|
conversation={props.conversation}
|
|
146
|
+
unread={props.unread}
|
|
137
147
|
/>
|
|
138
148
|
) : props.conversation.eventHistory.status === "redacted" &&
|
|
139
149
|
props.messages.length > 0 ? (
|
|
@@ -205,6 +215,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
|
|
|
205
215
|
}) => void;
|
|
206
216
|
transcript: TranscriptViewMessage[];
|
|
207
217
|
conversation: ConversationTranscript;
|
|
218
|
+
unread?: TranscriptUnreadRange;
|
|
208
219
|
}) {
|
|
209
220
|
const entries = useMemo(
|
|
210
221
|
() => groupTranscriptMessages(props.transcript),
|
|
@@ -214,6 +225,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
|
|
|
214
225
|
<TranscriptEntryList
|
|
215
226
|
entries={entries}
|
|
216
227
|
keyPrefix={props.conversation.conversationId}
|
|
228
|
+
unread={props.unread}
|
|
217
229
|
renderContext={(entry) => (
|
|
218
230
|
<TranscriptRailEvent
|
|
219
231
|
kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
|
|
@@ -314,11 +326,26 @@ function TranscriptEntryList(props: {
|
|
|
314
326
|
renderReasoning: (entry: TranscriptReasoningEntry) => ReactNode;
|
|
315
327
|
renderSubagent: (entry: TranscriptSubagentEntry) => ReactNode;
|
|
316
328
|
renderTool: (entry: TranscriptToolEntry) => ReactNode;
|
|
329
|
+
unread?: TranscriptUnreadRange;
|
|
317
330
|
}) {
|
|
318
331
|
const search = useTranscriptSearch();
|
|
319
332
|
const activityKeys = useRef(new Map<string, string>());
|
|
320
333
|
const claimedActivityKeys = new Set<string>();
|
|
321
334
|
const rows: ReactNode[] = [];
|
|
335
|
+
// Search results are not a reading order, so they get no marker.
|
|
336
|
+
const markerSeq = search.active
|
|
337
|
+
? undefined
|
|
338
|
+
: unreadMarkerSeq(props.entries, props.unread);
|
|
339
|
+
let unreadMarkerPending = markerSeq !== undefined;
|
|
340
|
+
// Entries are in seq order, so the first row that reaches the marker seq
|
|
341
|
+
// gets the marker.
|
|
342
|
+
const pushRow = (seq: number | undefined, row: ReactNode) => {
|
|
343
|
+
if (unreadMarkerPending && seq !== undefined && seq >= markerSeq!) {
|
|
344
|
+
unreadMarkerPending = false;
|
|
345
|
+
rows.push(<TranscriptUnreadMarker key="unread-marker" />);
|
|
346
|
+
}
|
|
347
|
+
rows.push(row);
|
|
348
|
+
};
|
|
322
349
|
|
|
323
350
|
const renderEntry = (entry: TranscriptEntry): ReactNode => {
|
|
324
351
|
if (entry.kind === "subagent") return props.renderSubagent(entry);
|
|
@@ -359,8 +386,16 @@ function TranscriptEntryList(props: {
|
|
|
359
386
|
keyPrefix: props.keyPrefix,
|
|
360
387
|
knownKeys: activityKeys.current,
|
|
361
388
|
});
|
|
362
|
-
|
|
363
|
-
|
|
389
|
+
const lastSeq = lastEntrySeq(activityEntries);
|
|
390
|
+
// The marker goes before the group when any of it is unread.
|
|
391
|
+
pushRow(
|
|
392
|
+
lastSeq,
|
|
393
|
+
<TranscriptRow
|
|
394
|
+
anchorKey={activityKey}
|
|
395
|
+
indent
|
|
396
|
+
key={activityKey}
|
|
397
|
+
seq={lastSeq}
|
|
398
|
+
>
|
|
364
399
|
<TranscriptActivityGroup
|
|
365
400
|
entries={visibleEntries}
|
|
366
401
|
renderEntry={renderEntry}
|
|
@@ -373,8 +408,9 @@ function TranscriptEntryList(props: {
|
|
|
373
408
|
|
|
374
409
|
if (!search.active || entryMatchesSearch(entry, search.normalizedQuery)) {
|
|
375
410
|
const rowKey = `${props.keyPrefix}:${entry.key}`;
|
|
376
|
-
|
|
377
|
-
|
|
411
|
+
pushRow(
|
|
412
|
+
entry.seq,
|
|
413
|
+
<TranscriptRow anchorKey={rowKey} key={rowKey} seq={entry.seq}>
|
|
378
414
|
{renderEntry(entry)}
|
|
379
415
|
</TranscriptRow>,
|
|
380
416
|
);
|
|
@@ -391,6 +427,39 @@ function TranscriptEntryList(props: {
|
|
|
391
427
|
return rows;
|
|
392
428
|
}
|
|
393
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Find the seq of the first unread row. Entries are in seq order, and pending
|
|
432
|
+
* sends without a seq come last, so a binary search finds the first seq after
|
|
433
|
+
* the read position. Rows after the range arrived after open, so they get no
|
|
434
|
+
* marker.
|
|
435
|
+
*/
|
|
436
|
+
function unreadMarkerSeq(
|
|
437
|
+
entries: readonly TranscriptEntry[],
|
|
438
|
+
unread: TranscriptUnreadRange | undefined,
|
|
439
|
+
): number | undefined {
|
|
440
|
+
if (!unread) return undefined;
|
|
441
|
+
let low = 0;
|
|
442
|
+
let high = entries.length;
|
|
443
|
+
while (low < high) {
|
|
444
|
+
const middle = (low + high) >> 1;
|
|
445
|
+
const seq = entries[middle]!.seq;
|
|
446
|
+
if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1;
|
|
447
|
+
else high = middle;
|
|
448
|
+
}
|
|
449
|
+
const seq = entries[low]?.seq;
|
|
450
|
+
return seq !== undefined && seq <= unread.throughSeq ? seq : undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function lastEntrySeq(
|
|
454
|
+
entries: readonly RenderedTranscriptEntry[],
|
|
455
|
+
): number | undefined {
|
|
456
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
457
|
+
const seq = entries[index]!.seq;
|
|
458
|
+
if (seq !== undefined) return seq;
|
|
459
|
+
}
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
394
463
|
/** Keep one activity group mounted while new or historical events extend either edge. */
|
|
395
464
|
function stableActivityGroupKey(args: {
|
|
396
465
|
claimedKeys: Set<string>;
|
|
@@ -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}
|