diff --git a/src/apps/chat/components/ChatMessageList.tsx b/src/apps/chat/components/ChatMessageList.tsx index c2467b1df..0d87d4173 100644 --- a/src/apps/chat/components/ChatMessageList.tsx +++ b/src/apps/chat/components/ChatMessageList.tsx @@ -127,6 +127,10 @@ export function ChatMessageList(props: { props.conversationHandler?.messagesDelete([messageId]); }, [props.conversationHandler]); + const handleMessageAppendFragment = React.useCallback((messageId: DMessageId, fragment: DMessageFragment) => { + props.conversationHandler?.messageFragmentAppend(messageId, fragment, false, false); + }, [props.conversationHandler]); + const handleMessageDeleteFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId) => { props.conversationHandler?.messageFragmentDelete(messageId, fragmentId, false, true); }, [props.conversationHandler]); @@ -286,6 +290,7 @@ export function ChatMessageList(props: { onMessageBeam={handleMessageBeam} onMessageBranch={handleMessageBranch} onMessageDelete={handleMessageDelete} + onMessageFragmentAppend={handleMessageAppendFragment} onMessageFragmentDelete={handleMessageDeleteFragment} onMessageFragmentReplace={handleMessageReplaceFragment} onMessageToggleUserFlag={handleMessageToggleUserFlag} diff --git a/src/apps/chat/components/message/ChatMessage.tsx b/src/apps/chat/components/message/ChatMessage.tsx index 040ef8085..c889283a4 100644 --- a/src/apps/chat/components/message/ChatMessage.tsx +++ b/src/apps/chat/components/message/ChatMessage.tsx @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow'; import TimeAgo from 'react-timeago'; import type { SxProps } from '@mui/joy/styles/types'; -import { Box, ButtonGroup, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy'; +import { Box, Button, ButtonGroup, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy'; import { ClickAwayListener, Popper } from '@mui/base'; import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined'; import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; @@ -33,10 +33,10 @@ import { copyToClipboard } from '~/common/util/clipboardUtils'; import { prettyBaseModel } from '~/common/util/modelUtils'; import { useUIPreferencesStore } from '~/common/state/store-ui'; -import { AttachmentFragments } from './fragments-attachment-text/TextAttachmentFragments'; import { ContentFragments } from './fragments-content/ContentFragments'; import { ImageAttachmentFragments } from './fragments-attachment-image/ImageAttachmentFragments'; import { ReplyToBubble } from './ReplyToBubble'; +import { TextAttachmentFragments } from './fragments-attachment-text/TextAttachmentFragments'; import { avatarIconSx, makeMessageAvatar, messageBackground, personaColumnSx } from './messageUtils'; import { useChatShowTextDiff } from '../../store-app-chat'; @@ -79,14 +79,15 @@ export function ChatMessage(props: { onMessageBeam?: (messageId: string) => Promise, onMessageBranch?: (messageId: string) => void, onMessageDelete?: (messageId: string) => void, + onMessageFragmentAppend?: (messageId: DMessageId, fragment: DMessageFragment) => void onMessageFragmentDelete?: (messageId: DMessageId, fragmentId: DMessageFragmentId) => void, onMessageFragmentReplace?: (messageId: DMessageId, fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => void, onMessageToggleUserFlag?: (messageId: string, flag: DMessageUserFlag) => void, onMessageTruncate?: (messageId: string) => void, onReplyTo?: (messageId: string, selectedText: string) => void, - onTextDiagram?: (messageId: string, text: string) => Promise - onTextImagine?: (text: string) => Promise - onTextSpeak?: (text: string) => Promise + onTextDiagram?: (messageId: string, text: string) => Promise, + onTextImagine?: (text: string) => Promise, + onTextSpeak?: (text: string) => Promise, sx?: SxProps, }) { @@ -125,7 +126,7 @@ export function ChatMessage(props: { } = props.message; // split the fragments: image attachments are first, then content fragments, then other attachment fragments - const [contentFragments, imageAttachments, otherAttachments] = classifyMessageFragments(messageFragments); + const [contentFragments, imageAttachments, nonImageAttachments] = classifyMessageFragments(messageFragments); const isUserStarred = messageHasUserFlag(props.message, 'starred'); @@ -143,7 +144,11 @@ export function ChatMessage(props: { // const textDiffs = useSanityTextDiffs(messageText, props.diffPreviousText, showDiff); - const { onMessageFragmentDelete, onMessageFragmentReplace } = props; + const { onMessageFragmentAppend, onMessageFragmentDelete, onMessageFragmentReplace } = props; + + const handleFragmentNew = React.useCallback(() => { + onMessageFragmentAppend?.(messageId, createTextContentFragment('')); + }, [messageId, onMessageFragmentAppend]); const handleFragmentDelete = React.useCallback((fragmentId: DMessageFragmentId) => { onMessageFragmentDelete?.(messageId, fragmentId); @@ -535,11 +540,22 @@ export function ChatMessage(props: { )} + {/* If editing and there's no content, have a button to create a new TextContentFragment */} + {isEditingText && !contentFragments.length && ( + + )} + {/* Content Fragments (iterating all to preserve the index) */} {/* Attachment Fragments */} - {/*{hasAttachments && (*/} - - {/*)}*/} + {nonImageAttachments.length >= 1 && ( + + )} {/* Reply-To Bubble */} {!!messageMetadata?.inReplyToText && ( diff --git a/src/apps/chat/components/message/fragments-attachment-image/ImageAttachmentFragments.tsx b/src/apps/chat/components/message/fragments-attachment-image/ImageAttachmentFragments.tsx index 37c7ef577..f3eb9aa50 100644 --- a/src/apps/chat/components/message/fragments-attachment-image/ImageAttachmentFragments.tsx +++ b/src/apps/chat/components/message/fragments-attachment-image/ImageAttachmentFragments.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import type { SxProps } from '@mui/joy/styles/types'; import { Box } from '@mui/joy'; -import type { DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.message'; +import type { DMessageAttachmentFragment, DMessageFragmentId, DMessageRole } from '~/common/stores/chat/chat.message'; import { ContentScaling, themeScalingMap } from '~/common/app.theme'; import { ContentPartImageRefDBlob, showImageDataRefInNewTab } from '../fragments-content/ContentPartImageRef'; @@ -23,11 +23,8 @@ const layoutSx: SxProps = { // layout display: 'flex', flexWrap: 'wrap', - // alignItems: 'center', - justifyContent: 'flex-end', - - // display: 'grid', - // gridTemplateColumns: 'repeat(auto-fit, minmax(max(min(100%, 400px), 100%/5), 1fr))', + // alignItems: 'center', // commented to keep them to the top + // justifyContent: 'flex-end', // commented as we do it dynamically gap: { xs: 0.5, md: 1 }, }; @@ -48,8 +45,6 @@ const imageSheetPatchSx: SxProps = { // override the style in RenderImageURL maxWidth: CARD_MAX_WIDTH, // very important to keep the aspect ratio maxHeight: CARD_MAX_HEIGHT, // very important to keep the aspect ratio - - // style // width: '100%', // height: '100%', // objectFit: 'cover', @@ -58,18 +53,23 @@ const imageSheetPatchSx: SxProps = { /** - * Shows image attachments in a Grid (responsive), similarly to + * Shows image attachments in a flexbox that wraps the images (overflowing by rows) + * Also see `TextAttachmentFragments` for the text version, and 'ContentFragments'. */ export function ImageAttachmentFragments(props: { imageAttachments: DMessageAttachmentFragment[], contentScaling: ContentScaling, + messageRole: DMessageRole, isMobile?: boolean, onFragmentDelete: (fragmentId: DMessageFragmentId) => void, }) { + const layoutSxMemo = React.useMemo((): SxProps => ({ + ...layoutSx, + justifyContent: props.messageRole === 'assistant' ? 'flex-start' : 'flex-end', + }), [props.messageRole]); - // memo the scaled image style - const scaledImageCardSx = React.useMemo((): SxProps => ({ + const cardStyleSxMemo = React.useMemo((): SxProps => ({ fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined, lineHeight: themeScalingMap[props.contentScaling]?.blockLineHeight ?? 1.75, ...imageSheetPatchSx, @@ -77,7 +77,7 @@ export function ImageAttachmentFragments(props: { return ( - + {/* render each image attachment */} {props.imageAttachments.map(attachmentFragment => { @@ -99,7 +99,7 @@ export function ImageAttachmentFragments(props: { imageHeight={imageRefPart.height} onOpenInNewTab={() => showImageDataRefInNewTab(dataRef)} onDeleteFragment={() => props.onFragmentDelete(attachmentFragment.fId)} - scaledImageSx={scaledImageCardSx} + scaledImageSx={cardStyleSxMemo} variant='attachment-card' /> ); diff --git a/src/apps/chat/components/message/fragments-attachment-text/TextAttachmentFragments.tsx b/src/apps/chat/components/message/fragments-attachment-text/TextAttachmentFragments.tsx index 60fd181c5..5340e06cd 100644 --- a/src/apps/chat/components/message/fragments-attachment-text/TextAttachmentFragments.tsx +++ b/src/apps/chat/components/message/fragments-attachment-text/TextAttachmentFragments.tsx @@ -1,28 +1,38 @@ import * as React from 'react'; + +import type { SxProps } from '@mui/joy/styles/types'; import { Box } from '@mui/joy'; import type { ContentScaling } from '~/common/app.theme'; -import type { DMessageAttachmentFragment, DMessageRole } from '~/common/stores/chat/chat.message'; +import type { DMessageAttachmentFragment, DMessageFragmentId, DMessageRole } from '~/common/stores/chat/chat.message'; import { ContentPartPlaceholder } from '../fragments-content/ContentPartPlaceholder'; + +const layoutSx: SxProps = {}; + + /** * Displays a list of 'cards' which are buttons with a mutually exclusive active state. * When one is active, there is a content part just right under (with the collapse mechanism in case it's a user role). * If one is clicked the content part (use ContentFragments with a single Fragment) is displayed. */ -export function AttachmentFragments(props: { - attachmentFragments: DMessageAttachmentFragment[], +export function TextAttachmentFragments(props: { + textFragments: DMessageAttachmentFragment[], messageRole: DMessageRole, contentScaling: ContentScaling, + isMobile?: boolean, + onFragmentDelete: (fragmentId: DMessageFragmentId) => void, }) { - if (!props.attachmentFragments.length) + if (!props.textFragments.length) return null; return ( - - {props.attachmentFragments.map((fragment, attachmentNumber) => ( + + + {/* render each text attachment */} + {props.textFragments.map((fragment, attachmentNumber) => ( ))} + ); } \ No newline at end of file