308aada5f9
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
import {
|
|
useRef,
|
|
useCallback,
|
|
useState,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
type Dispatch,
|
|
type MutableRefObject,
|
|
type RefObject,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import {
|
|
getChatDraftText,
|
|
setChatDraftText,
|
|
getChatDraftAttachments,
|
|
setChatDraftAttachments,
|
|
setChatDraftContent,
|
|
restoreAttachmentPreview,
|
|
} from "@/lib/local-storage";
|
|
import { formatBytes } from "@/lib/utils/format-bytes";
|
|
import { processFile, MAX_FILES, MAX_TOTAL_SIZE, type FileAttachment } from "./file-attachment";
|
|
import {
|
|
useAttachmentCountFeedback,
|
|
useAttachmentFileFeedback,
|
|
useAttachmentTotalSizeFeedback,
|
|
useUnreadablePastedImageFeedback,
|
|
} from "./use-attachment-file-feedback";
|
|
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
|
|
import type { ReviewComment } from "@/lib/state/slices/comments";
|
|
import type {
|
|
ChatSubmitPayload,
|
|
ChatSubmitResult,
|
|
MessageAttachment,
|
|
} from "./chat-input-container";
|
|
import type { TipTapInputHandle } from "./tiptap-input";
|
|
import type { ImagePasteIssue } from "./clipboard-attachments";
|
|
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";
|
|
|
|
type UseChatInputStateProps = {
|
|
sessionId: string | null;
|
|
workspaceId?: string | null;
|
|
isSending: boolean;
|
|
contextItems: ContextItem[];
|
|
pendingCommentsByFile?: Record<string, ReviewComment[]>;
|
|
/** Whether there are plan comments or PR feedback that allow empty-text submit */
|
|
hasContextComments?: boolean;
|
|
showRequestChangesTooltip: boolean;
|
|
onRequestChangesTooltipDismiss?: () => void;
|
|
onSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult;
|
|
};
|
|
|
|
function isPromiseLike(value: ChatSubmitResult): value is Promise<void | boolean> {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"then" in value &&
|
|
typeof value.then === "function"
|
|
);
|
|
}
|
|
|
|
function collectComments(
|
|
pendingCommentsByFile: Record<string, ReviewComment[]> | undefined,
|
|
): ReviewComment[] {
|
|
if (!pendingCommentsByFile) return [];
|
|
const allComments: ReviewComment[] = [];
|
|
for (const filePath of Object.keys(pendingCommentsByFile))
|
|
allComments.push(...pendingCommentsByFile[filePath]);
|
|
return allComments;
|
|
}
|
|
|
|
function toMessageAttachments(attachments: FileAttachment[]): MessageAttachment[] {
|
|
return attachments.map((att) => {
|
|
const attachment = {
|
|
type: att.isImage ? ("image" as const) : ("resource" as const),
|
|
mime_type: att.mimeType,
|
|
name: att.fileName,
|
|
size_bytes: att.size,
|
|
...(att.attachmentId ? { attachment_id: att.attachmentId } : { data: att.data ?? "" }),
|
|
...(att.deliveryMode === "path" && { delivery_mode: "path" as const }),
|
|
};
|
|
return attachment;
|
|
});
|
|
}
|
|
|
|
function clearDraft(sessionId: string | null) {
|
|
if (!sessionId) return;
|
|
setChatDraftText(sessionId, "");
|
|
setChatDraftContent(sessionId, null);
|
|
setChatDraftAttachments(sessionId, []);
|
|
}
|
|
|
|
function clearDraftText(sessionId: string | null) {
|
|
if (!sessionId) return;
|
|
setChatDraftText(sessionId, "");
|
|
setChatDraftContent(sessionId, null);
|
|
}
|
|
|
|
function attachmentSnapshot(attachments: FileAttachment[]): string {
|
|
return attachments.map((att) => `${att.id}:${att.deliveryMode ?? "prompt"}`).join("|");
|
|
}
|
|
|
|
type ClearSubmittedInputArgs = {
|
|
valueRef: MutableRefObject<string>;
|
|
submittedText: string;
|
|
attachmentsRef: MutableRefObject<FileAttachment[]>;
|
|
submittedAttachments: string;
|
|
inputRef: RefObject<TipTapInputHandle | null>;
|
|
setValue: Dispatch<SetStateAction<string>>;
|
|
setAttachments: Dispatch<SetStateAction<FileAttachment[]>>;
|
|
setHistoryIndex: Dispatch<SetStateAction<number>>;
|
|
resetHeight: () => void;
|
|
sessionId: string | null;
|
|
};
|
|
|
|
function clearSubmittedInput(args: ClearSubmittedInputArgs) {
|
|
// Abort if the user already typed new content since this submit started.
|
|
if (args.valueRef.current.trim() !== args.submittedText) return;
|
|
const attachmentsChanged =
|
|
attachmentSnapshot(args.attachmentsRef.current) !== args.submittedAttachments;
|
|
args.inputRef.current?.clear();
|
|
args.setValue("");
|
|
args.setHistoryIndex(-1);
|
|
args.resetHeight();
|
|
if (attachmentsChanged) {
|
|
clearDraftText(args.sessionId);
|
|
return;
|
|
}
|
|
args.setAttachments([]);
|
|
clearDraft(args.sessionId);
|
|
}
|
|
|
|
function handleSubmitResult(result: ChatSubmitResult, onSuccess: () => void) {
|
|
if (isPromiseLike(result)) {
|
|
// Submitters should show user-visible failure feedback and resolve false;
|
|
// rejected promises are unexpected, so preserve the draft and log them.
|
|
void result
|
|
.then((submitted) => {
|
|
if (submitted !== false) onSuccess();
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to submit chat input:", error);
|
|
});
|
|
return;
|
|
}
|
|
if (result !== false) onSuccess();
|
|
}
|
|
|
|
type SubmitDraftArgs = {
|
|
isSending: boolean;
|
|
workspaceId?: string | null;
|
|
valueRef: MutableRefObject<string>;
|
|
pendingCommentsRef: MutableRefObject<Record<string, ReviewComment[]> | undefined>;
|
|
attachmentsRef: MutableRefObject<FileAttachment[]>;
|
|
hasContextComments: boolean;
|
|
inputRef: RefObject<TipTapInputHandle | null>;
|
|
onSubmit: UseChatInputStateProps["onSubmit"];
|
|
clearArgs: Omit<ClearSubmittedInputArgs, "submittedText" | "submittedAttachments">;
|
|
};
|
|
|
|
type DraftChatSubmitPayload = Required<
|
|
Omit<ChatSubmitPayload, "planCommentRefs" | "previewFeedbackRefs" | "clientMessageId">
|
|
>;
|
|
|
|
function buildChatSubmitPayload(payload: DraftChatSubmitPayload): ChatSubmitPayload {
|
|
return {
|
|
message: payload.message,
|
|
...(payload.reviewComments.length > 0 ? { reviewComments: payload.reviewComments } : {}),
|
|
...(payload.attachments.length > 0 ? { attachments: payload.attachments } : {}),
|
|
...(payload.inlineMentions.length > 0 ? { inlineMentions: payload.inlineMentions } : {}),
|
|
...(payload.inlineTaskMentions.length > 0
|
|
? { inlineTaskMentions: payload.inlineTaskMentions }
|
|
: {}),
|
|
...(payload.entityReferences.length > 0 ? { entityReferences: payload.entityReferences } : {}),
|
|
};
|
|
}
|
|
|
|
function submitDraft(args: SubmitDraftArgs) {
|
|
if (args.isSending) return;
|
|
const trimmed = args.valueRef.current.trim();
|
|
const allComments = collectComments(args.pendingCommentsRef.current);
|
|
const currentAttachments = args.attachmentsRef.current;
|
|
if (
|
|
args.workspaceId &&
|
|
currentAttachments.some((attachment) => attachment.file && !attachment.attachmentId)
|
|
) {
|
|
return;
|
|
}
|
|
const submittedAttachments = attachmentSnapshot(currentAttachments);
|
|
const hasContent =
|
|
trimmed || allComments.length > 0 || currentAttachments.length > 0 || args.hasContextComments;
|
|
if (!hasContent) return;
|
|
const messageAttachments = toMessageAttachments(currentAttachments);
|
|
const inlineMentions = args.inputRef.current?.getMentions() ?? [];
|
|
const inlineTaskMentions = args.inputRef.current?.getTaskMentions() ?? [];
|
|
const entityReferences = args.inputRef.current?.getEntityReferences() ?? [];
|
|
const result = args.onSubmit(
|
|
buildChatSubmitPayload({
|
|
message: trimmed,
|
|
reviewComments: allComments,
|
|
attachments: messageAttachments,
|
|
inlineMentions,
|
|
inlineTaskMentions,
|
|
entityReferences,
|
|
}),
|
|
);
|
|
handleSubmitResult(result, () =>
|
|
clearSubmittedInput({
|
|
...args.clearArgs,
|
|
submittedText: trimmed,
|
|
submittedAttachments,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Attachment staging, retry, and draft persistence share one state machine so
|
|
// desktop and mobile composers cannot drift.
|
|
// eslint-disable-next-line max-lines-per-function
|
|
function useAttachments(sessionId: string | null, workspaceId?: string | null) {
|
|
const [attachments, setAttachments] = useState<FileAttachment[]>(() =>
|
|
sessionId ? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview) : [],
|
|
);
|
|
const warnAttachmentCountLimit = useAttachmentCountFeedback();
|
|
const rejectOversizedFile = useAttachmentFileFeedback();
|
|
const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback();
|
|
const warnUnreadablePastedImage = useUnreadablePastedImageFeedback();
|
|
const attachmentsRef = useRef(attachments);
|
|
const prevSessionIdRef = useRef(sessionId);
|
|
const prevPersistSessionIdRef = useRef(sessionId);
|
|
|
|
// Reset attachments from storage when session changes (runs before paint)
|
|
useLayoutEffect(() => {
|
|
if (sessionId === prevSessionIdRef.current) return;
|
|
prevSessionIdRef.current = sessionId;
|
|
const newAttachments = sessionId
|
|
? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview)
|
|
: [];
|
|
/* eslint-disable react-hooks/set-state-in-effect -- syncing from localStorage on session switch */
|
|
setAttachments(newAttachments);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
attachmentsRef.current = newAttachments;
|
|
}, [sessionId]);
|
|
|
|
// Persist attachments to storage when they change (for the same session)
|
|
useEffect(() => {
|
|
// Skip first invocation after session change to avoid overwriting freshly loaded attachments
|
|
if (sessionId !== prevPersistSessionIdRef.current) {
|
|
prevPersistSessionIdRef.current = sessionId;
|
|
return;
|
|
}
|
|
attachmentsRef.current = attachments;
|
|
if (sessionId) setChatDraftAttachments(sessionId, attachments);
|
|
}, [attachments, sessionId]);
|
|
|
|
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", uploadError: undefined });
|
|
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",
|
|
uploadError: undefined,
|
|
size: uploaded.size_bytes,
|
|
});
|
|
} catch (error) {
|
|
updateAttachment(attachment.id, {
|
|
uploadStatus: "failed",
|
|
uploadError: error instanceof ApiError ? error.message : t("task:attachmentUploadFailed"),
|
|
});
|
|
}
|
|
},
|
|
[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 addFiles = useCallback(
|
|
async (files: File[], issue?: ImagePasteIssue) => {
|
|
if (issue === "unreadable-image") {
|
|
warnUnreadablePastedImage();
|
|
return;
|
|
}
|
|
if (attachments.length >= MAX_FILES) {
|
|
warnAttachmentCountLimit();
|
|
return;
|
|
}
|
|
let acceptedCount = attachments.length;
|
|
let acceptedTotalSize = attachments.reduce((sum, att) => sum + att.size, 0);
|
|
for (const file of files) {
|
|
if (acceptedCount >= MAX_FILES) {
|
|
warnAttachmentCountLimit();
|
|
break;
|
|
}
|
|
if (rejectOversizedFile(file)) continue;
|
|
if (acceptedTotalSize + file.size > MAX_TOTAL_SIZE) {
|
|
warnAttachmentTotalSizeLimit();
|
|
break;
|
|
}
|
|
const attachment = await processFile(file);
|
|
if (attachment) {
|
|
acceptedCount += 1;
|
|
acceptedTotalSize += attachment.size;
|
|
const staged = {
|
|
...attachment,
|
|
uploadStatus: workspaceId ? ("pending" as const) : attachment.uploadStatus,
|
|
};
|
|
setAttachments((prev) => {
|
|
const next = [...prev, staged];
|
|
attachmentsRef.current = next;
|
|
return next;
|
|
});
|
|
void uploadPendingAttachment(staged);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
attachments,
|
|
rejectOversizedFile,
|
|
warnAttachmentCountLimit,
|
|
warnAttachmentTotalSizeLimit,
|
|
warnUnreadablePastedImage,
|
|
uploadPendingAttachment,
|
|
workspaceId,
|
|
],
|
|
);
|
|
|
|
const handleRemoveAttachment = useCallback((id: string) => {
|
|
const removed = attachmentsRef.current.find((attachment) => attachment.id === id);
|
|
setAttachments((prev) => {
|
|
const next = prev.filter((att) => att.id !== id);
|
|
attachmentsRef.current = next;
|
|
return next;
|
|
});
|
|
if (removed?.attachmentId) void deleteAttachment(removed.attachmentId).catch(() => undefined);
|
|
}, []);
|
|
|
|
const handleDeliveryModeChange = useCallback((id: string, deliveryMode: "prompt" | "path") => {
|
|
setAttachments((prev) => {
|
|
const next = prev.map((att) => (att.id === id ? { ...att, deliveryMode } : att));
|
|
attachmentsRef.current = next;
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const getAttachments = useCallback(
|
|
() => toMessageAttachments(attachmentsRef.current),
|
|
[attachmentsRef],
|
|
);
|
|
|
|
const handleRetryAttachment = useCallback(
|
|
(id: string) => {
|
|
const attachment = attachmentsRef.current.find((item) => item.id === id);
|
|
if (attachment) void uploadPendingAttachment(attachment);
|
|
},
|
|
[uploadPendingAttachment],
|
|
);
|
|
|
|
return {
|
|
attachments,
|
|
attachmentsRef,
|
|
setAttachments,
|
|
addFiles,
|
|
handleRemoveAttachment,
|
|
handleDeliveryModeChange,
|
|
handleRetryAttachment,
|
|
getAttachments,
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line max-lines-per-function
|
|
export function useChatInputState({
|
|
sessionId,
|
|
workspaceId,
|
|
isSending,
|
|
contextItems,
|
|
pendingCommentsByFile,
|
|
hasContextComments = false,
|
|
showRequestChangesTooltip,
|
|
onRequestChangesTooltipDismiss,
|
|
onSubmit,
|
|
}: UseChatInputStateProps) {
|
|
const { t } = useTranslation();
|
|
const [value, setValue] = useState(() => (sessionId ? getChatDraftText(sessionId) : ""));
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const inputRef = useRef<TipTapInputHandle>(null);
|
|
const valueRef = useRef(value);
|
|
const pendingCommentsRef = useRef(pendingCommentsByFile);
|
|
const prevTextSessionIdRef = useRef(sessionId);
|
|
|
|
const {
|
|
attachments,
|
|
attachmentsRef,
|
|
setAttachments,
|
|
addFiles,
|
|
handleRemoveAttachment,
|
|
handleDeliveryModeChange,
|
|
handleRetryAttachment,
|
|
getAttachments,
|
|
} = useAttachments(sessionId, workspaceId);
|
|
|
|
// Reset text value from storage when session changes (runs before paint)
|
|
useLayoutEffect(() => {
|
|
if (sessionId === prevTextSessionIdRef.current) return;
|
|
prevTextSessionIdRef.current = sessionId;
|
|
/* eslint-disable react-hooks/set-state-in-effect -- syncing from localStorage on session switch */
|
|
setValue(sessionId ? getChatDraftText(sessionId) : "");
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
valueRef.current = value;
|
|
pendingCommentsRef.current = pendingCommentsByFile;
|
|
}, [value, pendingCommentsByFile]);
|
|
|
|
const handleChange = useCallback(
|
|
(newValue: string) => {
|
|
// TipTap renders its document before React's passive effects flush. Keep
|
|
// the submit snapshot in sync with the editor event so a fast submit
|
|
// cannot observe the previous draft and silently no-op.
|
|
valueRef.current = newValue;
|
|
setValue(newValue);
|
|
if (sessionId) setChatDraftText(sessionId, newValue);
|
|
if (historyIndex >= 0) setHistoryIndex(-1);
|
|
if (showRequestChangesTooltip && onRequestChangesTooltipDismiss)
|
|
onRequestChangesTooltipDismiss();
|
|
},
|
|
[showRequestChangesTooltip, onRequestChangesTooltipDismiss, historyIndex, sessionId],
|
|
);
|
|
|
|
const handleSubmit = useCallback(
|
|
(resetHeight: () => void) => {
|
|
submitDraft({
|
|
isSending,
|
|
workspaceId,
|
|
valueRef,
|
|
pendingCommentsRef,
|
|
attachmentsRef,
|
|
hasContextComments,
|
|
inputRef,
|
|
onSubmit,
|
|
clearArgs: {
|
|
valueRef,
|
|
attachmentsRef,
|
|
inputRef,
|
|
setValue,
|
|
setAttachments,
|
|
setHistoryIndex,
|
|
resetHeight,
|
|
sessionId,
|
|
},
|
|
});
|
|
},
|
|
[
|
|
onSubmit,
|
|
isSending,
|
|
workspaceId,
|
|
sessionId,
|
|
attachmentsRef,
|
|
setAttachments,
|
|
hasContextComments,
|
|
],
|
|
);
|
|
|
|
const allItems = useMemo((): ContextItem[] => {
|
|
const attachmentItems: (ImageContextItem | FileAttachmentContextItem)[] = attachments.map(
|
|
(att) =>
|
|
att.isImage
|
|
? ({
|
|
kind: "image" as const,
|
|
id: `image:${att.id}`,
|
|
label: t("task:imageWithSize", { bytes: formatBytes(att.size) }),
|
|
attachment: att,
|
|
onRemove: () => handleRemoveAttachment(att.id),
|
|
onDeliveryModeChange: (mode) => handleDeliveryModeChange(att.id, mode),
|
|
onRetry: () => handleRetryAttachment(att.id),
|
|
} as ImageContextItem)
|
|
: ({
|
|
kind: "file-attachment" as const,
|
|
id: `file:${att.id}`,
|
|
label: att.fileName,
|
|
attachment: att,
|
|
onRemove: () => handleRemoveAttachment(att.id),
|
|
onRetry: () => handleRetryAttachment(att.id),
|
|
} as FileAttachmentContextItem),
|
|
);
|
|
return [...contextItems, ...attachmentItems];
|
|
}, [
|
|
contextItems,
|
|
attachments,
|
|
handleRemoveAttachment,
|
|
handleDeliveryModeChange,
|
|
handleRetryAttachment,
|
|
]);
|
|
|
|
const hasPendingAttachmentUploads =
|
|
Boolean(workspaceId) &&
|
|
attachments.some((attachment) => attachment.file && !attachment.attachmentId);
|
|
|
|
// prettier-ignore
|
|
return { value, attachments, inputRef, addFiles, handleChange, handleSubmit, allItems, getAttachments, hasPendingAttachmentUploads };
|
|
}
|