Files
kandev/apps/web/components/task-create-dialog-selectors.tsx
T
João Salavisa b082d5e1b9 refactor(web): use the shared formatBytes in chat attachments (#2071)
Delete the two byte-identical local formatBytes copies from the chat-attachment
modules and repoint all call sites at the canonical lib/utils/format-bytes.ts,
which its own doc comment asks new code to use.

The copies had no upper clamp (>= 1 TB rendered "1 undefined") and no NaN or
negative guard (NaN rendered "NaN undefined"). Size labels now carry one
decimal ("10.0 MB" instead of "10 MB") because the canonical helper does not
strip a trailing zero; unit and e2e expectations updated to match.
2026-07-30 22:40:50 +01:00

830 lines
25 KiB
TypeScript

/* eslint-disable max-lines -- groups all create-dialog selector subcomponents; splitting per-selector files is a separate refactor. */
"use client";
import { useEffect, useLayoutEffect, useRef, useState, memo, useCallback, useMemo } from "react";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconPaperclip } from "@tabler/icons-react";
import { Combobox } from "./combobox";
import { scoreBranch } from "@/lib/utils/branch-filter";
import { BranchRefreshButton } from "./branch-refresh-button";
import { formatBytes } from "@/lib/utils/format-bytes";
import {
processFile,
MAX_FILES,
MAX_TOTAL_SIZE,
type FileAttachment,
} from "@/components/task/chat/file-attachment";
import {
useAttachmentCountFeedback,
useAttachmentFileFeedback,
useAttachmentTotalSizeFeedback,
useUnreadablePastedImageFeedback,
} from "@/components/task/chat/use-attachment-file-feedback";
import {
readClipboardAttachments,
type ImagePasteIssue,
} from "@/components/task/chat/clipboard-attachments";
import { ContextZone } from "@/components/task/chat/context-items/context-zone";
import { MentionMenu } from "@/components/task/chat/mention-menu";
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { JiraImportBar } from "@/components/jira/jira-import-bar";
import { LinearImportBar } from "@/components/linear/linear-import-bar";
import { VoiceInputButton } from "@/components/task/chat/voice-input-button";
import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-mention";
import { cn } from "@/lib/utils";
const CURSOR_POINTER_CLASS = "cursor-pointer";
type AttachmentLimitRejection = "count" | "total-size" | null;
function acceptAttachmentsWithinLimits(
existing: FileAttachment[],
processed: FileAttachment[],
): { accepted: FileAttachment[]; rejection: AttachmentLimitRejection } {
let nextCount = existing.length;
let nextTotalSize = existing.reduce((sum, att) => sum + att.size, 0);
const accepted: FileAttachment[] = [];
for (const att of processed) {
if (nextCount >= MAX_FILES) return { accepted, rejection: "count" };
if (nextTotalSize + att.size > MAX_TOTAL_SIZE) {
return { accepted, rejection: "total-size" };
}
accepted.push(att);
nextCount += 1;
nextTotalSize += att.size;
}
return { accepted, rejection: null };
}
type RepositoryOption = {
value: string;
label: string;
renderLabel: () => React.ReactNode;
};
type RepositorySelectorProps = {
options: RepositoryOption[];
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
searchPlaceholder: string;
emptyMessage: string;
triggerClassName?: string;
};
export const RepositorySelector = memo(function RepositorySelector({
options,
value,
onValueChange,
disabled,
placeholder,
searchPlaceholder,
emptyMessage,
triggerClassName,
}: RepositorySelectorProps) {
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder={searchPlaceholder}
emptyMessage={emptyMessage}
disabled={disabled}
dropdownLabel="Repository"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={triggerClassName}
testId="repository-selector"
/>
);
});
type BranchOption = {
value: string;
label: string;
keywords?: string[];
renderLabel: () => React.ReactNode;
};
type BranchSelectorProps = {
options: BranchOption[];
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
searchPlaceholder: string;
emptyMessage: string;
triggerClassName?: string;
onRefresh?: () => void;
refreshing?: boolean;
fetchedAt?: string;
fetchError?: string;
loading?: boolean;
};
export const BranchSelector = memo(function BranchSelector({
options,
value,
onValueChange,
disabled,
placeholder,
searchPlaceholder,
emptyMessage,
triggerClassName,
onRefresh,
refreshing,
fetchedAt,
fetchError,
loading,
}: BranchSelectorProps) {
const headerAction = onRefresh ? (
<BranchRefreshButton
onRefresh={onRefresh}
refreshing={refreshing}
fetchedAt={fetchedAt}
fetchError={fetchError}
/>
) : undefined;
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder={searchPlaceholder}
emptyMessage={emptyMessage}
disabled={disabled}
dropdownLabel="Base Branch"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={triggerClassName}
testId="branch-selector"
filter={scoreBranch}
headerAction={headerAction}
loading={loading}
/>
);
});
type AgentSelectorProps = {
options: Array<{ value: string; label: string; renderLabel: () => React.ReactNode }>;
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
triggerClassName?: string;
popoverPortal?: boolean;
};
export const AgentSelector = memo(function AgentSelector({
options,
value,
onValueChange,
disabled,
placeholder,
triggerClassName,
popoverPortal,
}: AgentSelectorProps) {
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder="Search agents..."
emptyMessage="No agent found."
disabled={disabled}
dropdownLabel="Agent Profile"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={cn("min-w-0", triggerClassName)}
popoverPortal={popoverPortal}
testId="agent-profile-selector"
/>
);
});
type ExecutorSelectorProps = {
options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>;
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
triggerClassName?: string;
popoverPortal?: boolean;
};
export const ExecutorSelector = memo(function ExecutorSelector({
options,
value,
onValueChange,
disabled,
placeholder,
triggerClassName,
popoverPortal,
}: ExecutorSelectorProps) {
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
emptyMessage="No executor found."
disabled={disabled}
dropdownLabel="Executor"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={triggerClassName}
popoverPortal={popoverPortal}
showSearch={false}
/>
);
});
type ExecutorProfileSelectorProps = {
options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>;
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
triggerClassName?: string;
popoverPortal?: boolean;
};
export const ExecutorProfileSelector = memo(function ExecutorProfileSelector({
options,
value,
onValueChange,
disabled,
placeholder,
triggerClassName,
popoverPortal,
}: ExecutorProfileSelectorProps) {
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder="Search profiles..."
emptyMessage="No profile found."
disabled={disabled}
dropdownLabel="Executor Profile"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={cn("min-w-0", triggerClassName)}
popoverPortal={popoverPortal}
testId="executor-profile-selector"
/>
);
});
type InlineTaskNameProps = {
value: string;
onChange: (value: string) => void;
autoFocus?: boolean;
};
export const InlineTaskName = memo(function InlineTaskName({
value,
onChange,
autoFocus,
}: InlineTaskNameProps) {
const inputRef = useRef<HTMLInputElement>(null);
const hasFocusedRef = useRef(false);
useEffect(() => {
if (autoFocus && !hasFocusedRef.current && inputRef.current) {
hasFocusedRef.current = true;
inputRef.current.focus();
inputRef.current.select();
}
}, [autoFocus]);
return (
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Task name"
data-testid="task-title-input"
className="w-full min-w-0 max-w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring transition-colors"
/>
);
});
// Memoized description input to prevent re-rendering the entire dialog on every keystroke
type TaskFormInputsProps = {
isSessionMode: boolean;
autoFocus?: boolean;
initialDescription: string;
onDescriptionChange: (hasContent: boolean) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
descriptionValueRef: React.RefObject<TaskFormInputsHandle | null>;
disabled?: boolean;
placeholder?: string;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
jiraImport?: {
workspaceId: string | null;
disabled?: boolean;
onImport: (ticket: JiraTicket) => void;
};
linearImport?: {
workspaceId: string | null;
disabled?: boolean;
onImport: (issue: LinearIssue) => void;
};
/**
* Called after a non-empty voice transcript was inserted into the description
* when the user has voice auto-send enabled. The dialog wires this to a
* programmatic form submit so dictation can create the task hands-free.
*/
onVoiceAutoSend?: () => void;
};
function useFileAttachments() {
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const attachmentsRef = useRef<FileAttachment[]>([]);
const [isDragging, setIsDragging] = useState(false);
const warnAttachmentCountLimit = useAttachmentCountFeedback();
const rejectOversizedFile = useAttachmentFileFeedback();
const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback();
const warnUnreadablePastedImage = useUnreadablePastedImageFeedback();
const addFiles = useCallback(
async (files: File[], issue?: ImagePasteIssue) => {
if (issue === "unreadable-image") {
warnUnreadablePastedImage();
return;
}
const processed: FileAttachment[] = [];
for (const file of files) {
if (rejectOversizedFile(file)) continue;
const attachment = await processFile(file);
if (attachment) processed.push(attachment);
}
if (processed.length === 0) return;
const { accepted, rejection } = acceptAttachmentsWithinLimits(
attachmentsRef.current,
processed,
);
if (rejection === "count") {
warnAttachmentCountLimit();
} else if (rejection === "total-size") {
warnAttachmentTotalSizeLimit();
}
if (accepted.length === 0) return;
const next = [...attachmentsRef.current, ...accepted];
attachmentsRef.current = next;
setAttachments(next);
},
[
rejectOversizedFile,
warnAttachmentCountLimit,
warnAttachmentTotalSizeLimit,
warnUnreadablePastedImage,
],
);
const handleRemoveAttachment = useCallback((id: string) => {
const next = attachmentsRef.current.filter((att) => att.id !== id);
attachmentsRef.current = next;
setAttachments(next);
}, []);
return { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment };
}
function useAttachmentHandlers(
disabled: boolean | undefined,
addFiles: (files: File[], issue?: ImagePasteIssue) => Promise<void>,
setIsDragging: (v: boolean) => void,
) {
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (disabled) return;
const { files, issue } = readClipboardAttachments(e.clipboardData);
if (files.length > 0 || issue) {
e.preventDefault();
void addFiles(files, issue);
}
},
[disabled, addFiles],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
},
[disabled, setIsDragging],
);
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const { clientX, clientY } = e;
if (
clientX <= rect.left ||
clientX >= rect.right ||
clientY <= rect.top ||
clientY >= rect.bottom
) {
setIsDragging(false);
}
},
[setIsDragging],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files).filter((f) => f.size > 0 || f.type !== "");
if (files.length > 0) {
void addFiles(files);
}
},
[disabled, addFiles, setIsDragging],
);
return { handlePaste, handleDragOver, handleDragLeave, handleDrop };
}
function toContextItems(
attachments: FileAttachment[],
onRemove: (id: string) => void,
): ContextItem[] {
return attachments.map((att) =>
att.isImage
? ({
kind: "image" as const,
id: `image:${att.id}`,
label: `Image (${formatBytes(att.size)})`,
attachment: att,
onRemove: () => onRemove(att.id),
} as ImageContextItem)
: ({
kind: "file-attachment" as const,
id: `file:${att.id}`,
label: att.fileName,
attachment: att,
onRemove: () => onRemove(att.id),
} as FileAttachmentContextItem),
);
}
function AttachButton({ onClick, disabled }: { onClick: () => void; disabled?: boolean }) {
return (
<div className="flex items-center px-1 pb-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Attach files"
className={`h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-muted/40 hover:text-foreground ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
onClick={onClick}
disabled={disabled}
>
<IconPaperclip className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</div>
);
}
function useDescriptionInput(
initialDescription: string,
autoFocus: boolean | undefined,
descriptionValueRef: React.RefObject<TaskFormInputsHandle | null>,
onDescriptionChange: (hasContent: boolean) => void,
attachments: FileAttachment[],
) {
const [description, setDescription] = useState(initialDescription);
const descriptionRef = useRef(initialDescription);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Caret offset to restore after a non-typed value mutation (e.g. voice
// transcript splice). Consumed inside useLayoutEffect so the cursor lands
// before the next paint and the user sees no jump.
const pendingCursorRef = useRef<number | null>(null);
const setDescriptionValue = useCallback(
(newValue: string) => {
const hadContent = descriptionRef.current.trim().length > 0;
const hasContent = newValue.trim().length > 0;
descriptionRef.current = newValue;
setDescription(newValue);
if (hadContent !== hasContent) onDescriptionChange(hasContent);
},
[onDescriptionChange],
);
useEffect(() => {
const ref = descriptionValueRef as React.MutableRefObject<TaskFormInputsHandle | null>;
if (ref) {
ref.current = {
getValue: () => descriptionRef.current,
setValue: setDescriptionValue,
getAttachments: () => attachments,
};
}
}, [attachments, descriptionValueRef, setDescriptionValue]);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}, [description]);
useLayoutEffect(() => {
const pos = pendingCursorRef.current;
if (pos === null) return;
pendingCursorRef.current = null;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(pos, pos);
}, [description]);
useEffect(() => {
if (!autoFocus) return;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}, [autoFocus]);
const insertAtCursor = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const textarea = textareaRef.current;
const start = textarea?.selectionStart ?? description.length;
const end = textarea?.selectionEnd ?? description.length;
const charBefore = start > 0 ? description.charAt(start - 1) : "";
const needsLeadingSpace = charBefore !== "" && !/\s/.test(charBefore);
const insert = needsLeadingSpace ? ` ${trimmed}` : trimmed;
const next = description.slice(0, start) + insert + description.slice(end);
pendingCursorRef.current = start + insert.length;
setDescriptionValue(next);
},
[description, setDescriptionValue],
);
return { description, textareaRef, setDescriptionValue, insertAtCursor };
}
type FormInputsToolbarProps = {
onAttach: () => void;
disabled?: boolean;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
jiraImport?: TaskFormInputsProps["jiraImport"];
linearImport?: TaskFormInputsProps["linearImport"];
voice?: {
onTranscript: (text: string) => void;
onAutoSend?: () => void;
};
};
function FormInputsToolbar({
onAttach,
disabled,
onEnhancePrompt,
isEnhancingPrompt,
isUtilityConfigured,
jiraImport,
linearImport,
voice,
}: FormInputsToolbarProps) {
return (
<div className="flex items-center px-1 pb-1">
<AttachButton onClick={onAttach} disabled={disabled} />
{onEnhancePrompt && (
<EnhancePromptButton
onClick={onEnhancePrompt}
isLoading={isEnhancingPrompt ?? false}
isConfigured={isUtilityConfigured}
/>
)}
{jiraImport && (
<JiraImportBar
workspaceId={jiraImport.workspaceId}
disabled={jiraImport.disabled}
onImport={jiraImport.onImport}
/>
)}
{linearImport && (
<LinearImportBar
workspaceId={linearImport.workspaceId}
disabled={linearImport.disabled}
onImport={linearImport.onImport}
/>
)}
{voice && (
<div className="ml-auto flex items-center">
<VoiceInputButton
onTranscript={voice.onTranscript}
onAutoSend={voice.onAutoSend}
disabled={disabled}
/>
</div>
)}
</div>
);
}
function PromptMentionPopover({
mention,
}: {
mention: ReturnType<typeof useTaskCreatePromptMention>;
}) {
return (
<MentionMenu
isOpen={mention.isOpen}
isLoading={mention.isLoading}
position={mention.position}
items={mention.items}
query={mention.query}
selectedIndex={mention.selectedIndex}
onSelect={mention.handleSelect}
onClose={mention.closeMenu}
setSelectedIndex={mention.setSelectedIndex}
/>
);
}
function useTextareaHandlers(
mention: ReturnType<typeof useTaskCreatePromptMention>,
onKeyDown: TaskFormInputsProps["onKeyDown"],
) {
const { handleChange: mentionHandleChange, handleKeyDown: mentionHandleKeyDown } = mention;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => mentionHandleChange(e.target.value),
[mentionHandleChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
mentionHandleKeyDown(e);
if (e.defaultPrevented) return;
onKeyDown?.(e);
},
[mentionHandleKeyDown, onKeyDown],
);
return { handleChange, handleKeyDown };
}
function useFileInputClick(addFiles: (files: File[]) => Promise<void> | void) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) void addFiles(Array.from(files));
e.target.value = "";
},
[addFiles],
);
return { fileInputRef, handleAttachClick, handleFileInputChange };
}
function HiddenFileInput({
inputRef,
onChange,
}: {
inputRef: React.RefObject<HTMLInputElement | null>;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={onChange}
tabIndex={-1}
/>
);
}
function DraggingOverlay({ isDragging }: { isDragging: boolean }) {
if (!isDragging) return null;
return (
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none">
<span className="text-sm text-primary font-medium">Drop files here</span>
</div>
);
}
export const TaskFormInputs = memo(function TaskFormInputs({
isSessionMode,
autoFocus,
initialDescription,
onDescriptionChange,
onKeyDown,
descriptionValueRef,
disabled,
placeholder,
onEnhancePrompt,
isEnhancingPrompt,
isUtilityConfigured,
jiraImport,
linearImport,
onVoiceAutoSend,
}: TaskFormInputsProps) {
const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } =
useFileAttachments();
const { handlePaste, handleDragOver, handleDragLeave, handleDrop } = useAttachmentHandlers(
disabled,
addFiles,
setIsDragging,
);
const contextItems = useMemo(
() => toContextItems(attachments, handleRemoveAttachment),
[attachments, handleRemoveAttachment],
);
const { description, textareaRef, setDescriptionValue, insertAtCursor } = useDescriptionInput(
initialDescription,
autoFocus,
descriptionValueRef,
onDescriptionChange,
attachments,
);
const mention = useTaskCreatePromptMention({
textareaRef,
value: description,
onChange: setDescriptionValue,
});
const { handleChange, handleKeyDown } = useTextareaHandlers(mention, onKeyDown);
const { fileInputRef, handleAttachClick, handleFileInputChange } = useFileInputClick(addFiles);
const voiceBinding = useMemo(
() => ({ onTranscript: insertAtCursor, onAutoSend: onVoiceAutoSend }),
[insertAtCursor, onVoiceAutoSend],
);
return (
<div
className="relative min-w-0 max-w-full"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div
className={`min-w-0 max-w-full rounded-md border border-input bg-transparent focus-within:ring-2 focus-within:ring-ring/30 ${contextItems.length > 0 ? "ring-0" : ""}`}
>
<ContextZone items={contextItems} />
<Textarea
ref={textareaRef}
placeholder={
placeholder ??
(isSessionMode
? "Describe what you want the agent to do... (@ to insert a saved prompt)"
: "Write a prompt for the agent... (@ to insert a saved prompt)")
}
value={description}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
data-testid="task-description-input"
rows={2}
className={`min-w-0 max-w-full field-sizing-fixed wrap-anywhere border-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isSessionMode ? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" : "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]"}`}
required={isSessionMode}
disabled={disabled}
/>
<FormInputsToolbar
onAttach={handleAttachClick}
disabled={disabled}
onEnhancePrompt={onEnhancePrompt}
isEnhancingPrompt={isEnhancingPrompt}
isUtilityConfigured={isUtilityConfigured}
jiraImport={jiraImport}
linearImport={linearImport}
voice={voiceBinding}
/>
<HiddenFileInput inputRef={fileInputRef} onChange={handleFileInputChange} />
</div>
<PromptMentionPopover mention={mention} />
<DraggingOverlay isDragging={isDragging} />
</div>
);
});