Skip to content

Commit 1e2f5a6

Browse files
committed
Polish new agent dialog
1 parent c384179 commit 1e2f5a6

10 files changed

Lines changed: 991 additions & 449 deletions

desktop/src/features/agents/ui/AgentCreationPreview.tsx

Lines changed: 216 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import * as React from "react";
2-
import { Upload, X } from "lucide-react";
2+
import { Pencil, Plus } from "lucide-react";
3+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
34

45
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
56
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
67
import { cn } from "@/shared/lib/cn";
8+
import { Button } from "@/shared/ui/button";
9+
import { Input } from "@/shared/ui/input";
10+
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
711
import { Spinner } from "@/shared/ui/spinner";
812

913
function isAvatarFileDrag(event: React.DragEvent<HTMLElement>) {
1014
return Array.from(event.dataTransfer.types).includes("Files");
1115
}
1216

17+
const AVATAR_APPLY_MOTION_TRANSITION = {
18+
duration: 0.14,
19+
ease: [0.23, 1, 0.32, 1],
20+
} as const;
21+
1322
export function AgentCreationPreview({
1423
avatarUrl,
1524
disabled = false,
@@ -25,8 +34,14 @@ export function AgentCreationPreview({
2534
onUploadPendingChange?: (isPending: boolean) => void;
2635
onSelectAvatar: (avatarUrl: string) => void;
2736
}) {
37+
const avatarEditClipId = React.useId().replace(/:/g, "");
2838
const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false);
39+
const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false);
40+
const [avatarUrlDraft, setAvatarUrlDraft] = React.useState("");
41+
const [isAvatarUrlInputFocused, setIsAvatarUrlInputFocused] =
42+
React.useState(false);
2943
const avatarDragDepthRef = React.useRef(0);
44+
const shouldReduceMotion = useReducedMotion();
3045
const {
3146
inputRef: avatarUploadInputRef,
3247
isUploading,
@@ -46,6 +61,36 @@ export function AgentCreationPreview({
4661
};
4762
}, [isUploading, onUploadPendingChange]);
4863

64+
React.useEffect(() => {
65+
if (isAvatarMenuOpen) {
66+
setAvatarUrlDraft("");
67+
setIsAvatarUrlInputFocused(false);
68+
}
69+
}, [isAvatarMenuOpen]);
70+
71+
function applyAvatarUrl() {
72+
const nextUrl = avatarUrlDraft.trim();
73+
if (nextUrl.length === 0) {
74+
return;
75+
}
76+
clearUploadError();
77+
onSelectAvatar(nextUrl);
78+
setIsAvatarMenuOpen(false);
79+
}
80+
81+
const avatarClipStyle = React.useMemo<React.CSSProperties>(
82+
() => ({
83+
clipPath: `url(#${avatarEditClipId})`,
84+
transform: "translateZ(0)",
85+
}),
86+
[avatarEditClipId],
87+
);
88+
const hasAvatarUrlDraft = avatarUrlDraft.trim().length > 0;
89+
const hasAvatar = (avatarUrl?.trim().length ?? 0) > 0;
90+
const applyButtonTransition = shouldReduceMotion
91+
? { duration: 0 }
92+
: AVATAR_APPLY_MOTION_TRANSITION;
93+
4994
const handleAvatarDragEnter = React.useCallback(
5095
(event: React.DragEvent<HTMLFieldSetElement>) => {
5196
if (disabled || !isAvatarFileDrag(event)) {
@@ -109,12 +154,93 @@ export function AgentCreationPreview({
109154
[clearUploadError, disabled, isUploading, uploadAvatarFile],
110155
);
111156

157+
const avatarMenuContent = (
158+
<PopoverContent align="center" className="w-72 space-y-1 p-1">
159+
<button
160+
className="flex min-h-9 w-full items-center rounded-lg px-2.5 text-left text-sm outline-hidden transition-colors duration-150 ease-out hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
161+
disabled={disabled || isUploading}
162+
onClick={() => {
163+
clearUploadError();
164+
openUploadPicker();
165+
setIsAvatarMenuOpen(false);
166+
}}
167+
type="button"
168+
>
169+
Upload an image
170+
</button>
171+
<form
172+
className="flex min-h-9 items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors duration-150 ease-out focus-within:bg-muted/50"
173+
onSubmit={(event) => {
174+
event.preventDefault();
175+
applyAvatarUrl();
176+
}}
177+
>
178+
<label className="sr-only" htmlFor="agent-avatar-url">
179+
Use a URL
180+
</label>
181+
<Input
182+
autoCapitalize="none"
183+
autoComplete="off"
184+
autoCorrect="off"
185+
className={cn(
186+
"h-7 min-w-0 flex-1 border-0 bg-transparent px-0 py-0 text-sm shadow-none outline-none focus-visible:ring-0",
187+
isAvatarUrlInputFocused
188+
? "placeholder:text-muted-foreground/55"
189+
: "placeholder:text-popover-foreground",
190+
)}
191+
disabled={disabled || isUploading}
192+
id="agent-avatar-url"
193+
onBlur={() => setIsAvatarUrlInputFocused(false)}
194+
onChange={(event) => setAvatarUrlDraft(event.target.value)}
195+
onFocus={() => setIsAvatarUrlInputFocused(true)}
196+
placeholder={isAvatarUrlInputFocused ? "https://..." : "Use a URL"}
197+
spellCheck={false}
198+
value={avatarUrlDraft}
199+
/>
200+
<AnimatePresence initial={false}>
201+
{hasAvatarUrlDraft ? (
202+
<motion.div
203+
animate={{ opacity: 1, scale: 1, width: "auto" }}
204+
className="overflow-hidden"
205+
exit={{ opacity: 0, scale: 0.96, width: 0 }}
206+
initial={{ opacity: 0, scale: 0.96, width: 0 }}
207+
key="apply-avatar-url"
208+
transition={applyButtonTransition}
209+
>
210+
<Button
211+
className="h-7 px-2 text-xs"
212+
disabled={disabled || isUploading}
213+
size="xs"
214+
type="submit"
215+
>
216+
Apply
217+
</Button>
218+
</motion.div>
219+
) : null}
220+
</AnimatePresence>
221+
</form>
222+
{hasAvatar && onClearAvatar ? (
223+
<button
224+
className="flex min-h-9 w-full items-center rounded-lg px-2.5 text-left text-sm text-destructive outline-hidden transition-colors duration-150 ease-out hover:bg-destructive/10 focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
225+
disabled={disabled || isUploading}
226+
onClick={() => {
227+
onClearAvatar();
228+
setIsAvatarMenuOpen(false);
229+
}}
230+
type="button"
231+
>
232+
Remove avatar
233+
</button>
234+
) : null}
235+
</PopoverContent>
236+
);
237+
112238
return (
113239
<div className="mx-auto w-full max-w-[220px] lg:sticky lg:top-0">
114240
<fieldset
115241
aria-label="Agent avatar preview"
116242
className={cn(
117-
"group/avatar-preview relative m-0 aspect-[4/5] min-h-[240px] min-w-0 overflow-hidden rounded-xl border border-border/70 bg-muted/50 p-0 shadow-xs transition-[background-color,border-color,box-shadow] duration-150",
243+
"group/avatar-preview relative m-0 flex min-h-[190px] min-w-0 flex-col items-center justify-center gap-3 rounded-xl border border-transparent p-0 transition-[background-color,border-color,box-shadow] duration-150",
118244
isDragOverAvatar &&
119245
"border-dashed border-primary/70 bg-primary/5 ring-2 ring-primary/15",
120246
)}
@@ -131,50 +257,100 @@ export function AgentCreationPreview({
131257
type="file"
132258
/>
133259

134-
<div className="absolute inset-0 flex items-center justify-center">
135-
<ProfileAvatar
136-
avatarUrl={avatarUrl}
137-
className="h-36 w-36 text-4xl"
138-
label={label}
139-
/>
260+
<div className="relative h-36 w-36">
261+
{hasAvatar ? (
262+
<>
263+
<svg
264+
aria-hidden="true"
265+
className="pointer-events-none absolute inset-0 h-full w-full"
266+
fill="none"
267+
height="144"
268+
viewBox="0 0 144 144"
269+
width="144"
270+
xmlns="http://www.w3.org/2000/svg"
271+
>
272+
<clipPath clipPathUnits="userSpaceOnUse" id={avatarEditClipId}>
273+
<path
274+
clipRule="evenodd"
275+
d="M100.734 83.3298C102.415 84.1574 104.616 83.8757 105.495 82.2207C109.647 74.3981 112 65.4738 112 56C112 25.0721 86.9279 0 56 0C25.0721 0 0 25.0721 0 56C0 86.9279 25.0721 112 56 112C65.4738 112 74.3981 109.647 82.2207 105.495C83.8757 104.616 84.1574 102.415 83.3298 100.734C82.4783 99.0047 82 97.0582 82 95C82 87.8203 87.8203 82 95 82C97.0582 82 99.0047 82.4783 100.734 83.3298Z"
276+
fillRule="evenodd"
277+
transform="translate(-25.875 -25.875) scale(1.575)"
278+
/>
279+
</clipPath>
280+
</svg>
281+
282+
<div className="relative h-full w-full" style={avatarClipStyle}>
283+
<ProfileAvatar
284+
avatarUrl={avatarUrl}
285+
className={cn(
286+
"h-full w-full text-4xl transition-shadow duration-150",
287+
isDragOverAvatar && "ring-2 ring-primary/30",
288+
)}
289+
label={label}
290+
/>
291+
</div>
292+
293+
<div className="absolute bottom-0 right-0 z-10 flex h-[42px] w-[42px] items-center justify-center rounded-full bg-background">
294+
<Popover
295+
open={isAvatarMenuOpen}
296+
onOpenChange={setIsAvatarMenuOpen}
297+
>
298+
<PopoverTrigger asChild>
299+
<button
300+
aria-label="Edit avatar"
301+
className="flex h-9 w-9 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground shadow-lg transition-[background-color,scale] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90 disabled:hover:scale-100"
302+
disabled={disabled || isUploading}
303+
title="Edit avatar"
304+
type="button"
305+
>
306+
{isUploading ? (
307+
<Spinner
308+
aria-label="Uploading avatar"
309+
className="h-4 w-4 border-2"
310+
/>
311+
) : (
312+
<Pencil className="h-4 w-4" />
313+
)}
314+
</button>
315+
</PopoverTrigger>
316+
{avatarMenuContent}
317+
</Popover>
318+
</div>
319+
</>
320+
) : (
321+
<Popover open={isAvatarMenuOpen} onOpenChange={setIsAvatarMenuOpen}>
322+
<PopoverTrigger asChild>
323+
<button
324+
aria-label="Add avatar"
325+
className={cn(
326+
"flex h-full w-full items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-primary shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-70",
327+
isDragOverAvatar &&
328+
"border-primary/70 bg-primary/5 ring-2 ring-primary/15",
329+
)}
330+
disabled={disabled || isUploading}
331+
title="Add avatar"
332+
type="button"
333+
>
334+
{isUploading ? (
335+
<Spinner
336+
aria-label="Uploading avatar"
337+
className="h-4 w-4 border-2"
338+
/>
339+
) : (
340+
<Plus aria-hidden="true" className="h-14 w-14" />
341+
)}
342+
</button>
343+
</PopoverTrigger>
344+
{avatarMenuContent}
345+
</Popover>
346+
)}
140347
</div>
141348

142349
{uploadErrorMessage ? (
143-
<p className="absolute inset-x-3 bottom-12 rounded-md bg-background/95 px-2 py-1 text-center text-xs text-destructive shadow-xs">
350+
<p className="max-w-full rounded-md bg-background/95 px-2 py-1 text-center text-xs text-destructive shadow-xs">
144351
{uploadErrorMessage}
145352
</p>
146353
) : null}
147-
148-
<div className="absolute inset-x-3 bottom-3 flex justify-center gap-2">
149-
<button
150-
className="inline-flex h-8 translate-y-1 items-center justify-center gap-1.5 rounded-full border border-border/70 bg-background/90 px-3 text-xs font-medium text-foreground opacity-0 shadow-xs transition-[background-color,opacity,transform] duration-150 hover:bg-muted focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover/avatar-preview:translate-y-0 group-hover/avatar-preview:opacity-100 group-focus-within/avatar-preview:translate-y-0 group-focus-within/avatar-preview:opacity-100"
151-
disabled={disabled || isUploading}
152-
onClick={() => {
153-
clearUploadError();
154-
openUploadPicker();
155-
}}
156-
type="button"
157-
>
158-
{isUploading ? (
159-
<Spinner className="h-3.5 w-3.5 border-2" />
160-
) : (
161-
<Upload className="h-3.5 w-3.5" />
162-
)}
163-
{isUploading ? "Uploading..." : "Edit avatar"}
164-
</button>
165-
{avatarUrl && onClearAvatar ? (
166-
<button
167-
className="inline-flex h-8 translate-y-1 items-center justify-center gap-1.5 rounded-full border border-border/70 bg-background/90 px-3 text-xs font-medium text-foreground opacity-0 shadow-xs transition-[background-color,opacity,transform] duration-150 hover:bg-muted focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring group-hover/avatar-preview:translate-y-0 group-hover/avatar-preview:opacity-100 group-focus-within/avatar-preview:translate-y-0 group-focus-within/avatar-preview:opacity-100"
168-
disabled={disabled || isUploading}
169-
onClick={onClearAvatar}
170-
title="Remove avatar"
171-
type="button"
172-
>
173-
<X className="h-3.5 w-3.5" />
174-
Remove
175-
</button>
176-
) : null}
177-
</div>
178354
</fieldset>
179355
</div>
180356
);

0 commit comments

Comments
 (0)