Files
kandev/apps/web/components/task/chat/use-chat-input-state.ts
T
2026-06-16 13:58:48 +01:00

381 lines
12 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 {
processFile,
formatBytes,
MAX_FILES,
MAX_TOTAL_SIZE,
type FileAttachment,
} from "./file-attachment";
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { DiffComment } from "@/lib/diff/types";
import type { ChatSubmitResult, MessageAttachment } from "./chat-input-container";
import type { TipTapInputHandle } from "./tiptap-input";
import type { TaskMentionData } from "@/hooks/use-inline-mention";
type UseChatInputStateProps = {
sessionId: string | null;
isSending: boolean;
contextItems: ContextItem[];
pendingCommentsByFile?: Record<string, DiffComment[]>;
/** Whether there are plan comments or PR feedback that allow empty-text submit */
hasContextComments?: boolean;
showRequestChangesTooltip: boolean;
onRequestChangesTooltipDismiss?: () => void;
onSubmit: (
message: string,
reviewComments?: DiffComment[],
attachments?: MessageAttachment[],
inlineMentions?: ContextFile[],
inlineTaskMentions?: TaskMentionData[],
) => 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, DiffComment[]> | undefined,
): DiffComment[] {
if (!pendingCommentsByFile) return [];
const allComments: DiffComment[] = [];
for (const filePath of Object.keys(pendingCommentsByFile))
allComments.push(...pendingCommentsByFile[filePath]);
return allComments;
}
function toMessageAttachments(attachments: FileAttachment[]): MessageAttachment[] {
return attachments.map((att) =>
att.isImage
? {
type: "image" as const,
data: att.data,
mime_type: att.mimeType,
name: att.fileName,
...(att.deliveryMode === "path" && { delivery_mode: "path" as const }),
}
: {
type: "resource" as const,
data: att.data,
mime_type: att.mimeType,
name: att.fileName,
delivery_mode: "path" as const,
},
);
}
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;
valueRef: MutableRefObject<string>;
pendingCommentsRef: MutableRefObject<Record<string, DiffComment[]> | undefined>;
attachmentsRef: MutableRefObject<FileAttachment[]>;
hasContextComments: boolean;
inputRef: RefObject<TipTapInputHandle | null>;
onSubmit: UseChatInputStateProps["onSubmit"];
clearArgs: Omit<ClearSubmittedInputArgs, "submittedText" | "submittedAttachments">;
};
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;
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 result = args.onSubmit(
trimmed,
allComments.length > 0 ? allComments : undefined,
messageAttachments.length > 0 ? messageAttachments : undefined,
inlineMentions.length > 0 ? inlineMentions : undefined,
inlineTaskMentions.length > 0 ? inlineTaskMentions : undefined,
);
handleSubmitResult(result, () =>
clearSubmittedInput({
...args.clearArgs,
submittedText: trimmed,
submittedAttachments,
}),
);
}
function useAttachments(sessionId: string | null) {
const [attachments, setAttachments] = useState<FileAttachment[]>(() =>
sessionId ? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview) : [],
);
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 addFiles = useCallback(
async (files: File[]) => {
if (attachments.length >= MAX_FILES) {
console.warn(`Maximum ${MAX_FILES} files allowed`);
return;
}
const currentTotalSize = attachments.reduce((sum, att) => sum + att.size, 0);
for (const file of files) {
if (attachments.length >= MAX_FILES) break;
if (currentTotalSize + file.size > MAX_TOTAL_SIZE) {
console.warn("Total attachment size limit exceeded");
break;
}
const attachment = await processFile(file);
if (attachment) setAttachments((prev) => [...prev, attachment]);
}
},
[attachments],
);
const handleRemoveAttachment = useCallback((id: string) => {
setAttachments((prev) => {
const next = prev.filter((att) => att.id !== id);
attachmentsRef.current = next;
return next;
});
}, []);
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],
);
return {
attachments,
attachmentsRef,
setAttachments,
addFiles,
handleRemoveAttachment,
handleDeliveryModeChange,
getAttachments,
};
}
export function useChatInputState({
sessionId,
isSending,
contextItems,
pendingCommentsByFile,
hasContextComments = false,
showRequestChangesTooltip,
onRequestChangesTooltipDismiss,
onSubmit,
}: UseChatInputStateProps) {
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,
getAttachments,
} = useAttachments(sessionId);
// 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) => {
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,
valueRef,
pendingCommentsRef,
attachmentsRef,
hasContextComments,
inputRef,
onSubmit,
clearArgs: {
valueRef,
attachmentsRef,
inputRef,
setValue,
setAttachments,
setHistoryIndex,
resetHeight,
sessionId,
},
});
},
[onSubmit, isSending, 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: `Image (${formatBytes(att.size)})`,
attachment: att,
onRemove: () => handleRemoveAttachment(att.id),
onDeliveryModeChange: (mode) => handleDeliveryModeChange(att.id, mode),
} as ImageContextItem)
: ({
kind: "file-attachment" as const,
id: `file:${att.id}`,
label: att.fileName,
attachment: att,
onRemove: () => handleRemoveAttachment(att.id),
} as FileAttachmentContextItem),
);
return [...contextItems, ...attachmentItems];
}, [contextItems, attachments, handleRemoveAttachment, handleDeliveryModeChange]);
// prettier-ignore
return { value, attachments, inputRef, addFiles, handleChange, handleSubmit, allItems, getAttachments };
}