Files
kandev/apps/web/components/task-create-dialog-selectors.tsx
T
2026-09-13 16:59:54 +01:00

1088 lines
35 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 { 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 type { RichTextInputHandle } from "@/components/task/chat/rich-text-input";
import { TaskPromptReferenceEditor } from "@/components/task-prompt-reference-editor";
import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview";
import { composeLaunchPreviewPrompt } from "@/components/task-create-dialog-launch-preview";
import {
TaskCreateLaunchPreviewContent,
TaskCreateLaunchPreviewToggle,
} from "@/components/task-create-dialog-launch-preview-control";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { JiraImportBar } from "@/components/jira/jira-import-bar";
import { LinearImportBar } from "@/components/linear/linear-import-bar";
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";
import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-restore";
import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api";
import { ApiError } from "@/lib/api/client";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
composerIdentity,
composerInsertionText,
useStablePluginComposerCapability,
} from "@/lib/plugins/composer-capability";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
export { BranchSelector } from "./branch-selector";
export type { BranchOption, BranchSelectorProps } from "./branch-selector";
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={t("task:repository2")}
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={triggerClassName}
testId="repository-selector"
/>
);
});
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) {
const { t } = useTranslation();
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder={t("task:searchAgents")}
emptyMessage={t("task:noAgentFound")}
disabled={disabled}
dropdownLabel={t("task:agentProfile2")}
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) {
const { t } = useTranslation();
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
emptyMessage={t("task:noExecutorFound")}
disabled={disabled}
dropdownLabel={t("task:executor2")}
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) {
const { t } = useTranslation();
return (
<Combobox
options={options}
value={value}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder={t("task:searchProfiles")}
emptyMessage={t("task:noProfileFound")}
disabled={disabled}
dropdownLabel={t("task:executorProfile2")}
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 { t } = useTranslation();
const { inputRef, clampChange } = useTaskTitleSelectionRestore(value);
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(clampChange(e))}
placeholder={t("task:taskName")}
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;
promptReferencesEnabled?: boolean;
taskId?: string | null;
workspaceId?: string | null;
autoFocus?: boolean;
initialDescription: string;
onDescriptionChange: (hasContent: boolean) => void;
onPendingAttachmentUploadsChange?: (pending: boolean) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
descriptionValueRef: React.RefObject<TaskFormInputsHandle | null>;
disabled?: boolean;
placeholder?: string;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
launchPreview?: TaskCreateLaunchPreview | null;
jiraImport?: {
workspaceId: string | null;
disabled?: boolean;
onImport: (ticket: JiraTicket) => void;
};
linearImport?: {
workspaceId: string | null;
disabled?: boolean;
onImport: (issue: LinearIssue) => void;
};
/**
* Submits the form the way the native submit control does, for a plugin
* composer action that finished producing text (dictation, for instance).
* The dialog wires this to its own submit handler, so validation, gating
* and error handling stay native.
*/
onComposerSubmit?: () => boolean | Promise<boolean>;
};
// eslint-disable-next-line max-lines-per-function
function useFileAttachments(
workspaceId: string | null | undefined,
onPendingAttachmentUploadsChange?: (pending: boolean) => void,
) {
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const attachmentsRef = useRef<FileAttachment[]>([]);
const updateAttachment = useCallback((id: string, update: Partial<FileAttachment>) => {
setAttachments((prev) => {
const next = prev.map((attachment) =>
attachment.id === id ? { ...attachment, ...update } : attachment,
);
attachmentsRef.current = next;
return next;
});
}, []);
const uploadPendingAttachment = useCallback(
async (attachment: FileAttachment) => {
if (!workspaceId || !attachment.file || attachment.attachmentId) return;
updateAttachment(attachment.id, { uploadStatus: "uploading" });
try {
const uploaded = await uploadAttachment(attachment.file, {
workspaceId,
kind: attachment.isImage ? "image" : "resource",
deliveryMode: attachment.deliveryMode,
});
if (!attachmentsRef.current.some((current) => current.id === attachment.id)) {
void deleteAttachment(uploaded.attachment_id).catch(() => undefined);
return;
}
updateAttachment(attachment.id, {
attachmentId: uploaded.attachment_id,
uploadStatus: "ready",
size: uploaded.size_bytes,
});
} catch (error) {
updateAttachment(attachment.id, {
uploadStatus: "failed",
uploadError: error instanceof ApiError ? error.message : t("task:uploadFailed"),
});
}
},
[updateAttachment, workspaceId],
);
useEffect(() => {
if (!workspaceId) return;
for (const attachment of attachmentsRef.current) {
if (attachment.file && !attachment.attachmentId && attachment.uploadStatus !== "uploading") {
void uploadPendingAttachment(attachment);
}
}
}, [uploadPendingAttachment, workspaceId]);
const [isDragging, setIsDragging] = useState(false);
const warnAttachmentCountLimit = useAttachmentCountFeedback();
const rejectOversizedFile = useAttachmentFileFeedback();
const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback();
const warnUnreadablePastedImage = useUnreadablePastedImageFeedback();
useEffect(() => {
onPendingAttachmentUploadsChange?.(
attachments.some((attachment) => attachment.file && !attachment.attachmentId),
);
}, [attachments, onPendingAttachmentUploadsChange, workspaceId]);
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);
for (const attachment of accepted) void uploadPendingAttachment(attachment);
},
[
rejectOversizedFile,
warnAttachmentCountLimit,
warnAttachmentTotalSizeLimit,
warnUnreadablePastedImage,
uploadPendingAttachment,
],
);
const handleRemoveAttachment = useCallback((id: string) => {
const removed = attachmentsRef.current.find((attachment) => attachment.id === id);
const next = attachmentsRef.current.filter((att) => att.id !== id);
attachmentsRef.current = next;
setAttachments(next);
if (removed?.attachmentId) void deleteAttachment(removed.attachmentId).catch(() => undefined);
}, []);
const handleRetryAttachment = useCallback(
(id: string) => {
const attachment = attachmentsRef.current.find((item) => item.id === id);
if (attachment) void uploadPendingAttachment(attachment);
},
[uploadPendingAttachment],
);
return {
attachments,
isDragging,
setIsDragging,
addFiles,
handleRemoveAttachment,
handleRetryAttachment,
};
}
function useAttachmentHandlers(
disabled: boolean | undefined,
addFiles: (files: File[], issue?: ImagePasteIssue) => Promise<void>,
setIsDragging: (v: boolean) => void,
) {
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLElement>) => {
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,
onRetry: (id: string) => void,
): ContextItem[] {
return attachments.map((att) =>
att.isImage
? ({
kind: "image" as const,
id: `image:${att.id}`,
label: t("task:imageWithSize", { bytes: formatBytes(att.size) }),
attachment: att,
onRemove: () => onRemove(att.id),
onRetry: () => onRetry(att.id),
} as ImageContextItem)
: ({
kind: "file-attachment" as const,
id: `file:${att.id}`,
label: att.fileName,
attachment: att,
onRemove: () => onRemove(att.id),
onRetry: () => onRetry(att.id),
} as FileAttachmentContextItem),
);
}
function AttachButton({ onClick, disabled }: { onClick: () => void; disabled?: boolean }) {
const { t } = useTranslation();
return (
<div className="flex items-center px-1 pb-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("task:attachFiles")}
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>{t("task:attachFiles")}</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. a plugin
// 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) => {
// Read the synchronous ref, not the render snapshot: a plugin can
// insert twice (or insert then submit) inside one callback, before React
// has re-rendered with the first insertion.
const current = descriptionRef.current;
const textarea = textareaRef.current;
// A caret we set but have not applied yet outranks the DOM's: after a
// programmatic insertion the textarea still reports the pre-insert
// selection until the layout effect below runs, so a second insertion in
// the same callback would splice at the old offset.
const pending = pendingCursorRef.current;
const start = pending ?? textarea?.selectionStart ?? current.length;
const end = pending ?? textarea?.selectionEnd ?? current.length;
const insert = composerInsertionText(text, start > 0 ? current.charAt(start - 1) : "");
if (!insert) return;
const next = current.slice(0, start) + insert + current.slice(end);
pendingCursorRef.current = start + insert.length;
setDescriptionValue(next);
},
[setDescriptionValue],
);
return { description, descriptionRef, textareaRef, setDescriptionValue, insertAtCursor };
}
type FormInputsToolbarProps = {
onAttach: () => void;
disabled?: boolean;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
isUtilityConfigured?: boolean;
launchPreview?: TaskCreateLaunchPreview | null;
isLaunchPromptPreview: boolean;
onToggleLaunchPromptPreview: () => void;
jiraImport?: TaskFormInputsProps["jiraImport"];
linearImport?: TaskFormInputsProps["linearImport"];
pluginActions?: React.ReactNode;
};
function FormInputsToolbar({
onAttach,
disabled,
onEnhancePrompt,
isEnhancingPrompt,
isUtilityConfigured,
launchPreview,
isLaunchPromptPreview,
onToggleLaunchPromptPreview,
jiraImport,
linearImport,
pluginActions,
}: 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}
/>
)}
{launchPreview?.stepPrompt.trim() && (
<TaskCreateLaunchPreviewToggle
active={isLaunchPromptPreview}
disabled={disabled}
stepName={launchPreview.stepName}
onToggle={onToggleLaunchPromptPreview}
/>
)}
{jiraImport && (
<JiraImportBar
workspaceId={jiraImport.workspaceId}
disabled={jiraImport.disabled}
onImport={jiraImport.onImport}
/>
)}
{linearImport && (
<LinearImportBar
workspaceId={linearImport.workspaceId}
disabled={linearImport.disabled}
onImport={linearImport.onImport}
/>
)}
<div className="ml-auto flex items-center">{pluginActions}</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 useCreationComposerPluginActions(args: {
isSessionMode: boolean;
taskId: string | null;
disabled: boolean;
description: string;
descriptionRef: React.RefObject<string>;
focusComposer: () => boolean;
insertAtCursor: (text: string) => void;
submit?: () => boolean | Promise<boolean>;
}) {
const { isMobile } = useResponsiveBreakpoint();
const surface = args.isSessionMode ? "new-session" : "task-create";
const composer = useStablePluginComposerCapability(
{
insertText: (text) => {
args.insertAtCursor(text);
return true;
},
focus: args.focusComposer,
// Gate on the synchronous ref for the same reason the chat composer
// reads its editor: insert-then-submit in one callback happens before
// React re-renders with the new description.
submit: async () => {
if (args.disabled || !args.descriptionRef.current.trim() || !args.submit) return false;
return await args.submit();
},
},
composerIdentity(surface, args.taskId, null),
);
return (
<PluginSlot
name={args.isSessionMode ? "new-session-input-actions" : "task-create-input-actions"}
slotProps={{
surface,
presentation: isMobile ? "mobile" : "desktop",
taskId: args.taskId,
activeSessionId: null,
sessionIds: [],
disabled: args.disabled,
submittable: !args.disabled && args.description.trim().length > 0,
composer,
}}
/>
);
}
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, e.target.selectionStart),
[mentionHandleChange],
);
const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => mentionHandleKeyDown(e),
[mentionHandleKeyDown],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.defaultPrevented) return;
onKeyDown?.(e);
},
[onKeyDown],
);
return { handleChange, handleKeyDownCapture, 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 }) {
const { t } = useTranslation();
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">{t("task:dropFilesHere")}</span>
</div>
);
}
type TaskDescriptionInputProps = {
isLaunchPromptPreview: boolean;
launchPromptPreview: string;
promptReferencesEnabled: boolean;
referenceInputRef: React.RefObject<RichTextInputHandle | null>;
description: string;
onDescriptionChange: (value: string) => void;
descriptionPlaceholder: string;
isSessionMode: boolean;
autoFocus?: boolean;
disabled?: boolean;
onKeyDown: TaskFormInputsProps["onKeyDown"];
onPaste: React.ClipboardEventHandler<HTMLElement>;
handleChange: React.ChangeEventHandler<HTMLTextAreaElement>;
handleKeyDownCapture: React.KeyboardEventHandler<HTMLTextAreaElement>;
handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement>;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
};
function TaskDescriptionInput({
isLaunchPromptPreview,
launchPromptPreview,
promptReferencesEnabled,
referenceInputRef,
description,
onDescriptionChange,
descriptionPlaceholder,
isSessionMode,
autoFocus,
disabled,
onKeyDown,
onPaste,
handleChange,
handleKeyDownCapture,
handleKeyDown,
textareaRef,
}: TaskDescriptionInputProps) {
if (isLaunchPromptPreview) {
return <TaskCreateLaunchPreviewContent content={launchPromptPreview} />;
}
if (promptReferencesEnabled) {
return (
<TaskPromptReferenceEditor
ref={referenceInputRef}
value={description}
onChange={onDescriptionChange}
placeholder={descriptionPlaceholder}
autoFocus={autoFocus}
disabled={disabled}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
);
}
return (
<Textarea
ref={textareaRef}
placeholder={descriptionPlaceholder}
value={description}
onChange={handleChange}
onKeyDownCapture={handleKeyDownCapture}
onKeyDown={handleKeyDown}
onPaste={onPaste}
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}
/>
);
}
// The input coordinates existing attachment, mention and plugin controls in one field.
// eslint-disable-next-line max-lines-per-function
export const TaskFormInputs = memo(function TaskFormInputs({
workspaceId,
isSessionMode,
promptReferencesEnabled = false,
autoFocus,
initialDescription,
onDescriptionChange,
onPendingAttachmentUploadsChange,
onKeyDown,
descriptionValueRef,
disabled,
placeholder,
onEnhancePrompt,
isEnhancingPrompt,
isUtilityConfigured,
launchPreview,
jiraImport,
linearImport,
onComposerSubmit,
taskId = null,
}: TaskFormInputsProps) {
const { t } = useTranslation();
const [isLaunchPromptPreview, setIsLaunchPromptPreview] = useState(false);
const hasLaunchPromptPreview = Boolean(launchPreview?.stepPrompt.trim());
useEffect(() => {
if (!hasLaunchPromptPreview) setIsLaunchPromptPreview(false);
}, [hasLaunchPromptPreview]);
const {
attachments,
isDragging,
setIsDragging,
addFiles,
handleRemoveAttachment,
handleRetryAttachment,
} = useFileAttachments(workspaceId, onPendingAttachmentUploadsChange);
const { handlePaste, handleDragOver, handleDragLeave, handleDrop } = useAttachmentHandlers(
disabled,
addFiles,
setIsDragging,
);
const contextItems = useMemo(
() => toContextItems(attachments, handleRemoveAttachment, handleRetryAttachment),
[attachments, handleRemoveAttachment, handleRetryAttachment],
);
const { description, descriptionRef, textareaRef, setDescriptionValue, insertAtCursor } =
useDescriptionInput(
initialDescription,
autoFocus,
descriptionValueRef,
onDescriptionChange,
attachments,
);
const referenceInputRef = useRef<RichTextInputHandle | null>(null);
const mention = useTaskCreatePromptMention({
textareaRef,
inputRef: promptReferencesEnabled ? referenceInputRef : undefined,
value: description,
onChange: setDescriptionValue,
promptInsertMode: promptReferencesEnabled ? "reference" : "inline",
});
const { handleChange, handleKeyDownCapture, handleKeyDown } = useTextareaHandlers(
mention,
onKeyDown,
);
const { fileInputRef, handleAttachClick, handleFileInputChange } = useFileInputClick(addFiles);
const descriptionPlaceholder =
placeholder ??
(isSessionMode ? t("task:describeWhatYouWantTheAgent") : t("task:writeAPromptForTheAgent"));
const insertComposerText = useCallback(
(text: string) => {
if (!promptReferencesEnabled) {
insertAtCursor(text);
return;
}
const input = referenceInputRef.current;
if (!input) return;
const current = descriptionRef.current;
const start = input.getSelectionStart();
const end = input.getSelectionEnd();
const insert = composerInsertionText(text, start > 0 ? current.charAt(start - 1) : "");
if (!insert) return;
input.insertText(insert, start, end);
},
[descriptionRef, insertAtCursor, promptReferencesEnabled],
);
const focusComposer = useCallback(() => {
if (promptReferencesEnabled) {
const input = referenceInputRef.current;
if (!input) return false;
input.focus();
return true;
}
if (!textareaRef.current) return false;
textareaRef.current.focus();
return true;
}, [promptReferencesEnabled, textareaRef]);
useEffect(() => {
if (!promptReferencesEnabled || !autoFocus) return;
const input = referenceInputRef.current;
if (!input) return;
const end = input.getValue().length;
input.focus();
input.setSelectionRange(end, end);
}, [autoFocus, promptReferencesEnabled]);
const pluginActions = useCreationComposerPluginActions({
isSessionMode,
taskId,
disabled: Boolean(disabled),
description,
descriptionRef,
focusComposer,
insertAtCursor: insertComposerText,
submit: onComposerSubmit,
});
const launchPromptPreview =
hasLaunchPromptPreview && launchPreview
? composeLaunchPreviewPrompt(launchPreview.stepPrompt, description)
: "";
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} />
<TaskDescriptionInput
isLaunchPromptPreview={isLaunchPromptPreview}
launchPromptPreview={launchPromptPreview}
promptReferencesEnabled={promptReferencesEnabled}
referenceInputRef={referenceInputRef}
description={description}
onDescriptionChange={setDescriptionValue}
descriptionPlaceholder={descriptionPlaceholder}
isSessionMode={isSessionMode}
autoFocus={autoFocus}
disabled={disabled}
onKeyDown={onKeyDown}
onPaste={handlePaste}
handleChange={handleChange}
handleKeyDownCapture={handleKeyDownCapture}
handleKeyDown={handleKeyDown}
textareaRef={textareaRef}
/>
<FormInputsToolbar
onAttach={handleAttachClick}
disabled={disabled}
onEnhancePrompt={onEnhancePrompt}
isEnhancingPrompt={isEnhancingPrompt}
isUtilityConfigured={isUtilityConfigured}
launchPreview={launchPreview}
isLaunchPromptPreview={isLaunchPromptPreview}
onToggleLaunchPromptPreview={() => setIsLaunchPromptPreview((active) => !active)}
jiraImport={jiraImport}
linearImport={linearImport}
pluginActions={pluginActions}
/>
<HiddenFileInput inputRef={fileInputRef} onChange={handleFileInputChange} />
</div>
{!promptReferencesEnabled && <PromptMentionPopover mention={mention} />}
<DraggingOverlay isDragging={isDragging} />
</div>
);
});