diff --git a/src/apps/chat/components/message/ChatMessage.tsx b/src/apps/chat/components/message/ChatMessage.tsx index a307f41e8..ee44c5dd6 100644 --- a/src/apps/chat/components/message/ChatMessage.tsx +++ b/src/apps/chat/components/message/ChatMessage.tsx @@ -52,6 +52,7 @@ import { useUIPreferencesStore } from '~/common/stores/store-ui'; import { BlockOpContinue } from './BlockOpContinue'; import { BlockOpOptions, optionsExtractFromFragments_dangerModifyFragment } from './BlockOpOptions'; import { BlockOpUpstreamResume } from './BlockOpUpstreamResume'; +import { ChatMessageEditAttachments, type EditModeAttachmentsHandle } from './ChatMessageEditAttachments'; import { ContentFragments } from './fragments-content/ContentFragments'; import { DocumentAttachmentFragments } from './fragments-attachment-doc/DocumentAttachmentFragments'; import { ImageAttachmentFragments } from './fragments-attachment-image/ImageAttachmentFragments'; @@ -179,6 +180,7 @@ export function ChatMessage(props: { const [contextMenuAnchor, setContextMenuAnchor] = React.useState(null); const [opsMenuAnchor, setOpsMenuAnchor] = React.useState(null); const [textContentEditState, setTextContentEditState] = React.useState(null); + const attachmentsEditRef = React.useRef(null); // external state const { adjContentScaling, disableMarkdown, doubleClickToEdit, uiComplexityMode } = useUIPreferencesStore(useShallow(state => ({ @@ -278,14 +280,25 @@ export function ChatMessage(props: { }, [handleFragmentDelete, handleFragmentReplace, messageFragments]); const handleApplyAllEdits = React.useCallback(async (withControl: boolean) => { - const state = textContentEditState || {}; + // 0. take state, including new attachment drafts BEFORE clearing state + const fragmentsEdits = textContentEditState || {}; + const newFragments = await attachmentsEditRef.current?.takeAllFragments() ?? []; + + // 1. clear edit state (unmounts EditModeAttachments, triggers cleanup) setTextContentEditState(null); - for (const [fragmentId, editedText] of Object.entries(state)) + + // 2A. apply text fragment edits + for (const [fragmentId, editedText] of Object.entries(fragmentsEdits)) handleApplyEdit(fragmentId, editedText); - // if the user pressed Ctrl, we begin a regeneration from here + + // 2B. append new attachment fragments + for (const fragment of newFragments) + onMessageFragmentAppend?.(messageId, fragment); + + // 3. if the user pressed Ctrl, we begin a regeneration from here if (withControl && onMessageAssistantFrom) await onMessageAssistantFrom(messageId, 0); - }, [handleApplyEdit, messageId, onMessageAssistantFrom, textContentEditState]); + }, [handleApplyEdit, messageId, onMessageAssistantFrom, onMessageFragmentAppend, textContentEditState]); const handleEditsApplyClicked = React.useCallback(() => handleApplyAllEdits(false), [handleApplyAllEdits]); @@ -836,6 +849,14 @@ export function ChatMessage(props: { /> )} + {/* [Edit Mode] Add new attachments (right below the Document Fragments) */} + {isEditingText && !!onMessageFragmentAppend && ( + + )} + {/* [SYSTEM, REAL] Image Attachment Fragments - just for a realistic display below the system instruction text/docs */} {fromSystem && imageAttachments.length >= 1 && ( Promise; +} + + +/** + * Encapsulates all attachment wiring for ChatMessage edit mode. + * Owns a standalone attachment drafts store (one per edit session). + * Exposes an imperative handle for the parent to "take" fragments on save. + */ +export const ChatMessageEditAttachments = React.forwardRef( + function EditModeAttachments(props, ref) { + + // state + const storeApiRef = React.useRef(null); + if (!storeApiRef.current) storeApiRef.current = createAttachmentDraftsVanillaStore(); // created only on mount + + // external state + const { + attachmentDrafts, + attachAppendClipboardItems, attachAppendCloudFile, attachAppendFile, attachAppendUrl, // attachAppendDataTransfer + attachmentsTakeAllFragments, + } = useAttachmentDrafts(storeApiRef.current, false, false, undefined, false); + const browseCapability = useBrowseCapability(); + + + // imperative handle for parent to take fragments on save + React.useImperativeHandle(ref, () => ({ + takeAllFragments: () => attachmentsTakeAllFragments('global', 'app-chat'), + }), [attachmentsTakeAllFragments]); + + + // [effect] cleanup on unmount - remove all drafts (deleted their DBlob assets, except for 'taken' ones) + React.useEffect(() => { + const store = storeApiRef.current; + return () => { + store?.getState().removeAllAttachmentDrafts(); + }; + }, []); + + + // handlers - composed from shared attachment source hooks + + const handleAttachFiles = useAttachHandler_Files(attachAppendFile); + const handleOpenCamera = useAttachHandler_CameraOpen(attachAppendFile); + const handleAttachScreenCapture = useAttachHandler_ScreenCapture(attachAppendFile); + const { openWebInputDialog, webInputDialogComponent } = useAttachHandler_UrlWebLinks(attachAppendUrl); + const { openGoogleDrivePicker, googleDrivePickerComponent } = useGoogleDrivePicker(attachAppendCloudFile, props.isMobile); + + // viewer render props - same pattern as ComposerAttachmentDraftsList.tsx:44-52 + const renderDocViewer = React.useCallback( + (part: React.ComponentProps['docPart'], onClose: () => void) => + , + [], + ); + + const renderImageViewer = React.useCallback( + (part: React.ComponentProps['imageRefPart'], onClose: () => void) => + , + [], + ); + + + return <> + + + + {/* [+] Attachment Sources menu */} + + + {/* Attachment Drafts list */} + {attachmentDrafts.length > 0 && ( + + )} + + + + {/* Modal portals */} + {webInputDialogComponent} + {googleDrivePickerComponent} + + ; + }, +);