@sentry/junior-dashboard 0.272.0 → 0.274.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 +3 -3
- package/dist/client.js +69 -67
- package/dist/index.js +3 -3
- 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/ConversationComposer.tsx +7 -4
- package/src/client/conversations/ConversationPage.tsx +3 -0
- package/src/client/conversations/ConversationSidebar.tsx +121 -60
- package/src/client/conversations/ConversationWorkspace.tsx +8 -3
- 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/composerAutocorrect.ts +12 -0
- package/src/client/conversations/conversationAge.ts +24 -0
- package/src/client/conversations/conversationOutbox.ts +19 -0
- package/src/client/conversations/conversationRoutes.ts +14 -5
- package/src/client/conversations/conversationSections.ts +64 -50
- 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/conversations/useConversationFinishedIndicators.ts +40 -8
- package/src/client/format.ts +8 -1
- package/src/client/pages/settings/SettingsPage.tsx +28 -0
- package/src/client/review-desk/ConversationReviewBar.tsx +20 -2
- package/src/client/review-desk/ReviewDeskPage.tsx +5 -1
- package/src/client/review-desk/queries.ts +34 -7
- package/src/client/types.ts +2 -0
- package/src/mock-reporting/routes.ts +1 -1
|
@@ -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
|
+
};
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "../components/ui/tooltip";
|
|
27
27
|
import { useDashboardOnline } from "../connection";
|
|
28
28
|
import { cn, dashboardComposerSurfaceClass } from "../styles";
|
|
29
|
+
import { useComposerAutocorrect } from "./composerAutocorrect";
|
|
29
30
|
|
|
30
31
|
const DRAFT_STORAGE_PREFIX = "junior:dashboard:conversation-draft:";
|
|
31
32
|
// Keep keystrokes off the storage path. Mobile Safari pays for every write.
|
|
@@ -132,6 +133,7 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
132
133
|
const [dragging, setDragging] = useState(false);
|
|
133
134
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
134
135
|
const online = useDashboardOnline();
|
|
136
|
+
const [autocorrect] = useComposerAutocorrect();
|
|
135
137
|
const id = useId();
|
|
136
138
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
137
139
|
const draftRef = useRef(initialDraft);
|
|
@@ -490,10 +492,11 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
490
492
|
</label>
|
|
491
493
|
<textarea
|
|
492
494
|
// Chat is technical: ids, paths, code. Keep the keyboard chat-like
|
|
493
|
-
// (Send) and suppress browser IME assist that fights identifiers
|
|
494
|
-
|
|
495
|
+
// (Send) and suppress browser IME assist that fights identifiers,
|
|
496
|
+
// unless the user turns autocorrect on in settings.
|
|
497
|
+
autoCapitalize={autocorrect ? "sentences" : "off"}
|
|
495
498
|
autoComplete="off"
|
|
496
|
-
autoCorrect="off"
|
|
499
|
+
autoCorrect={autocorrect ? "on" : "off"}
|
|
497
500
|
className="min-h-12 max-h-28 w-full resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 font-sans text-base leading-relaxed text-dashboard-text outline-none placeholder:text-dashboard-text-muted/65 md:min-h-14 md:max-h-[min(calc(12lh_+_1.5rem),40dvh)] md:px-4 md:py-3"
|
|
498
501
|
disabled={Boolean(props.restoreDraftOnError && createPending)}
|
|
499
502
|
enterKeyHint="send"
|
|
@@ -510,7 +513,7 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
510
513
|
placeholder="Message Junior…"
|
|
511
514
|
ref={textareaRef}
|
|
512
515
|
rows={1}
|
|
513
|
-
spellCheck={
|
|
516
|
+
spellCheck={autocorrect}
|
|
514
517
|
/>
|
|
515
518
|
<div
|
|
516
519
|
className={cn(
|
|
@@ -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}
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Archive,
|
|
4
|
+
ArchiveRestore,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
CircleAlert,
|
|
7
|
+
Inbox,
|
|
8
|
+
LockKeyhole,
|
|
9
|
+
SquarePen,
|
|
10
|
+
} from "lucide-react";
|
|
3
11
|
import { Link } from "react-router";
|
|
4
12
|
|
|
5
13
|
import { useArchiveConversation } from "./queries";
|
|
6
14
|
import { conversationPath } from "./conversationRoutes";
|
|
7
15
|
import {
|
|
8
16
|
conversationDisplayTitle,
|
|
9
|
-
slackLocationLabel,
|
|
10
17
|
visualStatusForConversation,
|
|
11
18
|
} from "../format";
|
|
19
|
+
import { formatCompactAge } from "./conversationAge";
|
|
12
20
|
import { cn } from "../styles";
|
|
13
21
|
import type { Conversation } from "../types";
|
|
14
22
|
import { Notice, NoticeAction } from "../components/Notice";
|
|
15
23
|
import { AnimatedList } from "./AnimatedList";
|
|
16
24
|
import {
|
|
17
|
-
attentionConversationIds,
|
|
18
25
|
buildConversationSections,
|
|
19
26
|
type ConversationSection,
|
|
20
27
|
} from "./conversationSections";
|
|
@@ -29,8 +36,8 @@ import {
|
|
|
29
36
|
TooltipContent,
|
|
30
37
|
TooltipTrigger,
|
|
31
38
|
} from "../components/ui/tooltip";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
39
|
+
import { ShimmerText } from "../components/ShimmerText";
|
|
40
|
+
import type { WaitingConversation } from "../review-desk/queries";
|
|
34
41
|
|
|
35
42
|
type ConversationSidebarEntry =
|
|
36
43
|
| {
|
|
@@ -55,11 +62,13 @@ export function ConversationSidebar(props: {
|
|
|
55
62
|
conversations: Conversation[];
|
|
56
63
|
error?: string;
|
|
57
64
|
finishedConversationIds: ReadonlySet<string>;
|
|
65
|
+
/** Opened while unread; keeps its unread place until another one opens. */
|
|
66
|
+
heldConversationId?: string;
|
|
58
67
|
loading: boolean;
|
|
59
68
|
query: string;
|
|
60
69
|
selectedId?: string;
|
|
61
70
|
timeZone: string;
|
|
62
|
-
|
|
71
|
+
waitingConversations: ReadonlyMap<string, WaitingConversation>;
|
|
63
72
|
/**
|
|
64
73
|
* `panel` = split-pane dock with internal scroll.
|
|
65
74
|
* `landing` = flow nav under create compose (parent owns scroll).
|
|
@@ -97,10 +106,11 @@ export function ConversationSidebar(props: {
|
|
|
97
106
|
() =>
|
|
98
107
|
conversationSidebarEntries(
|
|
99
108
|
buildConversationSections(props.conversations, {
|
|
100
|
-
attentionIds:
|
|
101
|
-
props.
|
|
102
|
-
props.finishedConversationIds,
|
|
103
|
-
|
|
109
|
+
attentionIds: new Set([
|
|
110
|
+
...props.waitingConversations.keys(),
|
|
111
|
+
...props.finishedConversationIds,
|
|
112
|
+
...(props.heldConversationId ? [props.heldConversationId] : []),
|
|
113
|
+
]),
|
|
104
114
|
nowMs: Date.now(),
|
|
105
115
|
timeZone: props.timeZone,
|
|
106
116
|
}),
|
|
@@ -110,8 +120,9 @@ export function ConversationSidebar(props: {
|
|
|
110
120
|
collapsedKeys,
|
|
111
121
|
props.conversations,
|
|
112
122
|
props.finishedConversationIds,
|
|
123
|
+
props.heldConversationId,
|
|
113
124
|
props.timeZone,
|
|
114
|
-
props.
|
|
125
|
+
props.waitingConversations,
|
|
115
126
|
],
|
|
116
127
|
);
|
|
117
128
|
return (
|
|
@@ -204,13 +215,13 @@ export function ConversationSidebar(props: {
|
|
|
204
215
|
) : (
|
|
205
216
|
<ConversationSidebarRow
|
|
206
217
|
conversation={entry.conversation}
|
|
207
|
-
finishedSinceSeen={props.finishedConversationIds.has(
|
|
208
|
-
entry.conversation.id,
|
|
209
|
-
)}
|
|
210
218
|
onArchiveError={handleArchiveError}
|
|
211
219
|
onArchived={handleArchived}
|
|
212
220
|
selected={entry.conversation.id === props.selectedId}
|
|
213
|
-
|
|
221
|
+
unread={props.finishedConversationIds.has(
|
|
222
|
+
entry.conversation.id,
|
|
223
|
+
)}
|
|
224
|
+
waiting={props.waitingConversations.get(
|
|
214
225
|
entry.conversation.id,
|
|
215
226
|
)}
|
|
216
227
|
/>
|
|
@@ -262,12 +273,15 @@ function ConversationRowsLoading() {
|
|
|
262
273
|
>
|
|
263
274
|
<span className="sr-only">Loading your conversations</span>
|
|
264
275
|
<Skeleton className="mb-1 ml-2.5 mt-1.5 h-2.5 w-12" />
|
|
265
|
-
{Array.from({ length:
|
|
266
|
-
<div
|
|
276
|
+
{Array.from({ length: 10 }, (_, index) => (
|
|
277
|
+
<div
|
|
278
|
+
className="flex items-center justify-between gap-3 rounded-lg py-2 pl-[1.625rem] pr-3"
|
|
279
|
+
key={index}
|
|
280
|
+
>
|
|
267
281
|
<Skeleton
|
|
268
282
|
className={cn("h-4", index % 3 === 0 ? "w-4/5" : "w-3/5")}
|
|
269
283
|
/>
|
|
270
|
-
<Skeleton className="h-3 w-
|
|
284
|
+
<Skeleton className="h-3 w-6 opacity-70" />
|
|
271
285
|
</div>
|
|
272
286
|
))}
|
|
273
287
|
</div>
|
|
@@ -301,11 +315,11 @@ function conversationSidebarEntries(
|
|
|
301
315
|
|
|
302
316
|
const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
303
317
|
conversation: Conversation;
|
|
304
|
-
finishedSinceSeen: boolean;
|
|
305
318
|
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
|
|
306
319
|
onArchived(conversation: Conversation): void;
|
|
307
320
|
selected: boolean;
|
|
308
|
-
|
|
321
|
+
unread: boolean;
|
|
322
|
+
waiting?: WaitingConversation;
|
|
309
323
|
}) {
|
|
310
324
|
const archive = useArchiveConversation(props.conversation.id, {
|
|
311
325
|
// Read the attempted direction from the mutation call, not the (possibly
|
|
@@ -321,60 +335,82 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
|
321
335
|
},
|
|
322
336
|
});
|
|
323
337
|
const status = visualStatusForConversation(props.conversation);
|
|
324
|
-
const location = slackLocationLabel(props.conversation, {
|
|
325
|
-
includeId: false,
|
|
326
|
-
});
|
|
327
338
|
const title = conversationDisplayTitle(props.conversation);
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
// Linked work is denser and more actionable than channel; hide channel when
|
|
331
|
-
// annotations own the meta row. Also skip a location that only restates the
|
|
332
|
-
// title (common for redacted private destinations).
|
|
333
|
-
const showLocation =
|
|
334
|
-
Boolean(location) &&
|
|
335
|
-
!hasAnnotations &&
|
|
336
|
-
location?.toLocaleLowerCase() !== title.toLocaleLowerCase();
|
|
337
|
-
const hasMeta = showLocation || hasAnnotations;
|
|
339
|
+
// Unread and waiting rows read like unread mail: a dot and a bold title.
|
|
340
|
+
const emphasized = props.unread || Boolean(props.waiting);
|
|
338
341
|
return (
|
|
339
|
-
<div
|
|
342
|
+
<div
|
|
343
|
+
className={cn(
|
|
344
|
+
"mobile-conversation-row group relative min-w-0 rounded-lg transition-colors hover:bg-dashboard-fill-hover",
|
|
345
|
+
props.selected && "bg-dashboard-surface-active",
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
340
348
|
<Link
|
|
341
349
|
aria-current={props.selected ? "page" : undefined}
|
|
342
|
-
className=
|
|
343
|
-
"block min-w-0 rounded-lg px-3 py-2 text-inherit no-underline transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus max-sm:pr-10",
|
|
344
|
-
props.selected && "bg-dashboard-surface-active",
|
|
345
|
-
)}
|
|
350
|
+
className="grid min-w-0 grid-cols-[0.375rem_minmax(0,1fr)_auto] items-center gap-x-2 rounded-lg px-3 py-1.5 text-inherit no-underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus max-sm:pr-10"
|
|
346
351
|
to={conversationPath(props.conversation.id)}
|
|
347
352
|
>
|
|
348
|
-
<
|
|
349
|
-
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
<span className="grid place-items-center">
|
|
354
|
+
{emphasized ? (
|
|
355
|
+
<span
|
|
356
|
+
aria-label={props.waiting ? "Waiting on you" : "Unread"}
|
|
357
|
+
className="size-1.5 shrink-0 rounded-full bg-dashboard-accent"
|
|
358
|
+
role="img"
|
|
359
|
+
/>
|
|
360
|
+
) : props.conversation.visibility === "private" ? (
|
|
361
|
+
<LockKeyhole
|
|
362
|
+
aria-label="Private conversation"
|
|
363
|
+
className="size-3 shrink-0 text-dashboard-text-faint"
|
|
355
364
|
/>
|
|
356
|
-
</div>
|
|
357
|
-
<div className="col-start-2 row-start-1 min-w-0 truncate font-sans text-sm font-medium leading-snug text-dashboard-text">
|
|
358
|
-
{title}
|
|
359
|
-
</div>
|
|
360
|
-
{hasMeta ? (
|
|
361
|
-
<div className="col-start-2 row-start-2 mt-1 flex min-w-0 items-center gap-1.5 font-sans text-xs leading-tight text-dashboard-text-muted">
|
|
362
|
-
{showLocation ? (
|
|
363
|
-
<span className="truncate">{location}</span>
|
|
364
|
-
) : null}
|
|
365
|
-
<ConversationSidebarAnnotations
|
|
366
|
-
annotations={props.conversation.sidebarAnnotations}
|
|
367
|
-
/>
|
|
368
|
-
</div>
|
|
369
365
|
) : null}
|
|
370
|
-
</
|
|
366
|
+
</span>
|
|
367
|
+
<span
|
|
368
|
+
className={cn(
|
|
369
|
+
"min-w-0 truncate font-sans text-sm leading-snug",
|
|
370
|
+
emphasized
|
|
371
|
+
? "font-semibold text-dashboard-text"
|
|
372
|
+
: status === "active"
|
|
373
|
+
? "text-dashboard-text"
|
|
374
|
+
: "text-dashboard-text-muted",
|
|
375
|
+
)}
|
|
376
|
+
>
|
|
377
|
+
{title}
|
|
378
|
+
</span>
|
|
379
|
+
<ConversationRowState
|
|
380
|
+
lastSeenAt={props.conversation.lastSeenAt}
|
|
381
|
+
status={status}
|
|
382
|
+
/>
|
|
371
383
|
</Link>
|
|
384
|
+
{props.waiting ? (
|
|
385
|
+
<Link
|
|
386
|
+
className="mb-1.5 ml-[1.625rem] mr-3 flex min-w-0 items-center gap-1.5 rounded-md border border-dashboard-border-strong bg-dashboard-surface-raised px-2 py-1 font-sans text-xs text-dashboard-text no-underline transition-colors hover:border-dashboard-border-interactive focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
|
|
387
|
+
to={conversationPath(props.conversation.id, { reviewItems: true })}
|
|
388
|
+
>
|
|
389
|
+
<Inbox
|
|
390
|
+
aria-hidden="true"
|
|
391
|
+
className="size-3 shrink-0 text-dashboard-text-muted"
|
|
392
|
+
/>
|
|
393
|
+
<span className="min-w-0 truncate">{props.waiting.title}</span>
|
|
394
|
+
{props.waiting.count > 1 ? (
|
|
395
|
+
<span className="ml-auto shrink-0 text-dashboard-text-faint">
|
|
396
|
+
+{props.waiting.count - 1} more
|
|
397
|
+
</span>
|
|
398
|
+
) : null}
|
|
399
|
+
<ChevronRight
|
|
400
|
+
aria-hidden="true"
|
|
401
|
+
className={cn(
|
|
402
|
+
"size-3 shrink-0 text-dashboard-text-muted",
|
|
403
|
+
props.waiting.count > 1 ? undefined : "ml-auto",
|
|
404
|
+
)}
|
|
405
|
+
/>
|
|
406
|
+
</Link>
|
|
407
|
+
) : null}
|
|
372
408
|
<Tooltip>
|
|
373
409
|
<TooltipTrigger
|
|
374
410
|
render={
|
|
375
411
|
<button
|
|
376
412
|
aria-label={`${props.conversation.archivedAt ? "Restore" : "Archive"} ${title}`}
|
|
377
|
-
className="absolute right-1.5 top-
|
|
413
|
+
className="absolute right-1.5 top-0.5 z-10 grid size-7 cursor-pointer place-items-center rounded-md bg-dashboard-surface-active text-dashboard-text-muted transition hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus sm:pointer-events-none sm:opacity-0 sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 disabled:cursor-not-allowed"
|
|
378
414
|
disabled={archive.isPending}
|
|
379
415
|
onClick={() =>
|
|
380
416
|
archive.mutate({
|
|
@@ -400,6 +436,31 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
|
|
|
400
436
|
);
|
|
401
437
|
});
|
|
402
438
|
|
|
439
|
+
/** Show the row age, or the status when the conversation runs or failed. */
|
|
440
|
+
function ConversationRowState(props: {
|
|
441
|
+
lastSeenAt: string;
|
|
442
|
+
status: ReturnType<typeof visualStatusForConversation>;
|
|
443
|
+
}) {
|
|
444
|
+
if (props.status === "active") {
|
|
445
|
+
return (
|
|
446
|
+
<ShimmerText
|
|
447
|
+
active
|
|
448
|
+
className="font-sans text-xs text-dashboard-text-muted"
|
|
449
|
+
>
|
|
450
|
+
Running
|
|
451
|
+
</ShimmerText>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (props.status === "failed") {
|
|
455
|
+
return <span className="font-sans text-xs text-rose-400">Failed</span>;
|
|
456
|
+
}
|
|
457
|
+
return (
|
|
458
|
+
<span className="font-sans text-xs tabular-nums text-dashboard-text-faint">
|
|
459
|
+
{formatCompactAge(props.lastSeenAt)}
|
|
460
|
+
</span>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
403
464
|
function ArchiveConversationErrorNotice(props: {
|
|
404
465
|
conversation: Conversation;
|
|
405
466
|
onDismiss(): void;
|