diff --git a/.cursor/rules/devtools-import-rules.mdc b/.cursor/rules/devtools-import-rules.mdc index 41ec7be4..8819b564 100644 --- a/.cursor/rules/devtools-import-rules.mdc +++ b/.cursor/rules/devtools-import-rules.mdc @@ -3,9 +3,13 @@ description: devtools import rules globs: packages/devtools/, *.tsx, *.ts alwaysApply: true --- - -# Devtools import rules - +--- +description: Import rules in Spark +globs: *.tsx, packages/devtools, *.ts +alwaysApply: true +--- +--- +The linter error "Import order needs to be fixed" can be quick resolved by running `npm run lint:fix`. See below for a summary of those rules. Sort imports using the convention shown below. See [App.tsx](mdc:packages/devtools/src/App.tsx) as an example. ` // imports from external packages are first, sorted alphabetically by package name, except react imports should always be the first import diff --git a/.cursor/rules/react-teams-sdk.mdc b/.cursor/rules/react-teams-sdk.mdc index 2474614f..c4ca64f5 100644 --- a/.cursor/rules/react-teams-sdk.mdc +++ b/.cursor/rules/react-teams-sdk.mdc @@ -3,13 +3,10 @@ description: React development in Spark globs: *.tsx, packages/devtools, *.ts alwaysApply: true --- ---- -description: React development in Spark -globs: packages/devtools/**/*.tsx,.ts ---- + # Summary - You are an expert AI programming assistant that primarily focuses on producing clear, readable React and TypeScript code. -- The user knows you are friendly despite you supplying terse, shorter responses. +- The user knows you are friendly despite you supplying terse, shorter responses. ## React rules - Follow react conventions from https://18.react.dev/ @@ -18,24 +15,44 @@ globs: packages/devtools/**/*.tsx,.ts - Keep <> React components when they exist. ## FluentUI +- Never use `shorthand` from FluentUI. - For colors, fonts, spacing, typography styles etc, use fluent ui's tokens object, found at https://github.com/microsoft/fluentui/tree/master/packages/tokens/src/global and https://react.fluentui.dev/?path=/docs/theme-colors--docs - Use FluentUI for all components like - + {value.reactions && value.reactions.length > 0 && ( +
0 && classes.reactionContainerVisible, + sendDirection === 'sent' && classes.reactionContainerSent + )} + > + {value.reactions.map((reaction) => ( + handleReactionClick(reaction)} + /> ))}
)} @@ -414,5 +179,4 @@ const ChatMessage: FC = memo( ); ChatMessage.displayName = 'ChatMessage'; - export default ChatMessage; diff --git a/packages/devtools/src/components/ChatMessage/ChatMessageContainer.styles.ts b/packages/devtools/src/components/ChatMessage/ChatMessageContainer.styles.ts index 9cd21abb..addd68cf 100644 --- a/packages/devtools/src/components/ChatMessage/ChatMessageContainer.styles.ts +++ b/packages/devtools/src/components/ChatMessage/ChatMessageContainer.styles.ts @@ -3,41 +3,54 @@ import { makeStyles, tokens } from '@fluentui/react-components'; const useChatContainerClasses = makeStyles({ messageRow: { display: 'flex', + alignItems: 'flex-start', + padding: tokens.spacingVerticalS, marginLeft: tokens.spacingHorizontalL, marginRight: tokens.spacingHorizontalL, - alignItems: 'flex-end', - padding: '0.5rem', }, + + messageContainer: { + display: 'flex', + flexDirection: 'column', + maxWidth: '80%', + '&:has([title="Edit message"])': { + flex: '1 1 100%', + }, + // for changing width when EditComposeBox is rendered + '&:has([title="Edit message"]) [data-ed]': { + flex: '1 1 100%', + }, + }, + messageGroupSent: { justifyContent: 'flex-end', }, + messageGroupReceived: { justifyContent: 'flex-start', }, - messageContainer: { - display: 'flex', - flexDirection: 'column', - maxWidth: '80%', - }, + badgeMessageContainer: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', flexDirection: 'row', gap: tokens.spacingHorizontalM, }, - sentTime: { - textAlign: 'right', + + timeMessageContainer: { + display: 'flex', + flexDirection: 'column', + position: 'relative', }, + + // Visual styles timestamp: { fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground3, marginBottom: tokens.spacingVerticalS, }, - timeMessageContainer: { - display: 'flex', - flexDirection: 'column', - position: 'relative', - transition: 'all 0.2s', + sentTime: { + textAlign: 'end', }, }); diff --git a/packages/devtools/src/components/ChatMessage/ChatMessageContainer.tsx b/packages/devtools/src/components/ChatMessage/ChatMessageContainer.tsx index 3fc70c96..36fd349b 100644 --- a/packages/devtools/src/components/ChatMessage/ChatMessageContainer.tsx +++ b/packages/devtools/src/components/ChatMessage/ChatMessageContainer.tsx @@ -22,18 +22,19 @@ const ChatMessageContainer: FC = memo(({ value, isConnected = fals const sendDirection = value.from?.user?.id === 'devtools' ? 'sent' : 'received'; const ariaLabel = sendDirection === 'sent' ? 'Sent message at' : 'Received message at'; + const messageRowClasses = mergeClasses( + classes.messageRow, + sendDirection === 'sent' ? classes.messageGroupSent : classes.messageGroupReceived + ); + return ( -
+
-
- {sendDirection === 'received' && } -
+
+ {sendDirection === 'received' && ( + + )} +
{value.createdDateTime ? ( = memo(({ value, isConnected = fals {formatMessageTime(value.createdDateTime)} - ) : ( - Timestamp unavailable - )} + ) : null} {children}
diff --git a/packages/devtools/src/components/ChatMessage/MessageAttachments.tsx b/packages/devtools/src/components/ChatMessage/MessageAttachments.tsx new file mode 100644 index 00000000..2f9d8aed --- /dev/null +++ b/packages/devtools/src/components/ChatMessage/MessageAttachments.tsx @@ -0,0 +1,116 @@ +import { FC, memo, useCallback, useMemo, useState } from 'react'; +import { Image } from '@fluentui/react-components'; +import { Attachment } from '@microsoft/spark.api'; + +import { AttachmentType } from '../../types/Attachment'; +import AttachmentsContainer from '../AttachmentsContainer/AttachmentsContainer'; +import Logger from '../Logger/Logger'; + +const childLog = Logger.child('MessageAttachments'); + +const SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/jpg'] as const; +type SupportedImageType = (typeof SUPPORTED_IMAGE_TYPES)[number]; + +const isValidImageType = (type: string | undefined): type is SupportedImageType => { + return !!type && SUPPORTED_IMAGE_TYPES.includes(type as SupportedImageType); +}; + +const isValidAttachment = (attachment: Attachment): boolean => { + return !!attachment && typeof attachment === 'object' && 'contentType' in attachment; +}; + +export interface MessageAttachmentsProps { + attachments: Attachment[]; + classes: Record; + onRemoveAttachment?: (index: number) => void; + showRemoveButtons?: boolean; +} + +const MessageAttachments: FC = memo( + ({ attachments, classes, onRemoveAttachment = () => {}, showRemoveButtons = false }) => { + const [failedImages, setFailedImages] = useState>(new Set()); + + const handleImageError = useCallback((attachment: Attachment) => { + const id = attachment.id || `${attachment.name}-${attachment.contentUrl}`; + childLog.error(`Failed to load image attachment: ${id}`, { attachment }); + setFailedImages((prev) => new Set(prev).add(id)); + }, []); + + const renderAttachment = useCallback( + (attachment: Attachment) => { + if (!isValidAttachment(attachment)) { + childLog.warn('Invalid attachment object', { attachment }); + return null; + } + + const imageId = attachment.id || `${attachment.name}-${attachment.contentUrl}`; + if (failedImages.has(imageId)) { + return null; + } + + if (!isValidImageType(attachment.contentType)) { + childLog.warn(`Unsupported image type: ${attachment.contentType}`); + return null; + } + + return ( + {attachment.name handleImageError(attachment)} + /> + ); + }, + [classes.attachmentImage, failedImages, handleImageError] + ); + + const { imageAttachments, nonImageAttachments } = useMemo(() => { + const validAttachments = attachments.filter(isValidAttachment); + return { + imageAttachments: validAttachments.filter((a) => isValidImageType(a.contentType)), + nonImageAttachments: validAttachments + .filter((a) => !isValidImageType(a.contentType)) + .map((attachment): AttachmentType => { + if (attachment.contentType?.startsWith('application/vnd.microsoft.card.')) { + return { + type: 'card', + content: attachment.content || null, + name: attachment.name || 'Untitled card', + }; + } + + return { + type: 'file', + content: attachment.contentUrl || attachment.content || null, + name: attachment.name || 'Untitled file', + }; + }), + }; + }, [attachments]); + + if (!attachments.length) { + return null; + } + + return ( + <> + {imageAttachments.length > 0 && ( +
{imageAttachments.map(renderAttachment)}
+ )} + {nonImageAttachments.length > 0 && ( + + )} + + ); + } +); + +MessageAttachments.displayName = 'MessageAttachments'; + +export default MessageAttachments; diff --git a/packages/devtools/src/components/ChatMessage/MessageReactionButton.tsx b/packages/devtools/src/components/ChatMessage/MessageReactionButton.tsx new file mode 100644 index 00000000..27db8a22 --- /dev/null +++ b/packages/devtools/src/components/ChatMessage/MessageReactionButton.tsx @@ -0,0 +1,44 @@ +import { FC, memo } from 'react'; +import { Button, mergeClasses, Tooltip } from '@fluentui/react-components'; +import { MessageReaction } from '@microsoft/spark.api'; + +import { messageReactions } from '../../types/MessageReactionsEmoji'; + +import useChatMessageStyles from './ChatMessage.styles'; + +export interface MessageReactionButtonProps { + reaction: MessageReaction; + isFromUser: boolean; + onReactionClick: () => void; +} + +const MessageReactionButton: FC = memo( + ({ reaction, isFromUser, onReactionClick }) => { + const classes = useChatMessageStyles(); + const reactionEmoji = messageReactions.find((r) => r.reaction === reaction.type)?.label; + + return ( + {reaction.type}} + relationship="label" + positioning={{ align: 'center', position: 'below' }} + > + + + ); + } +); + +MessageReactionButton.displayName = 'MessageReactionButton'; + +export default MessageReactionButton; diff --git a/packages/devtools/src/components/ChatMessage/ChatMessageDeleted.tsx b/packages/devtools/src/components/ChatMessage/MessageUpdate/ChatMessageDeleted.tsx similarity index 80% rename from packages/devtools/src/components/ChatMessage/ChatMessageDeleted.tsx rename to packages/devtools/src/components/ChatMessage/MessageUpdate/ChatMessageDeleted.tsx index 11d0a8a9..b8bd4909 100644 --- a/packages/devtools/src/components/ChatMessage/ChatMessageDeleted.tsx +++ b/packages/devtools/src/components/ChatMessage/MessageUpdate/ChatMessageDeleted.tsx @@ -1,18 +1,19 @@ import { FC, memo, useCallback } from 'react'; import { Link, mergeClasses } from '@fluentui/react-components'; +import { MessageUser } from '@microsoft/spark.api'; -import { MessageActionUIPayload } from '../../types/MessageActionUI'; - -import { useChatMessageStyles } from './ChatMessage.styles'; +import { MessageActionUIPayload } from '../../../types/MessageActionUI'; +import useChatMessageStyles from '../ChatMessage.styles'; interface ChatMessageDeletedProps { id: string; sendDirection: 'sent' | 'received'; onMessageAction: (action: MessageActionUIPayload) => Promise; + user?: MessageUser; } const ChatMessageDeleted: FC = memo( - ({ id, sendDirection, onMessageAction }) => { + ({ id, sendDirection, onMessageAction, user }) => { const classes = useChatMessageStyles(); const undoDelete = useCallback(async () => { @@ -20,8 +21,9 @@ const ChatMessageDeleted: FC = memo( id, type: 'messageUpdate', eventType: 'undeleteMessage', + user, }); - }, [id, onMessageAction]); + }, [id, onMessageAction, user]); return (
diff --git a/packages/devtools/src/components/ChatMessage/MessageUpdate/ChatMessageEdit.tsx b/packages/devtools/src/components/ChatMessage/MessageUpdate/ChatMessageEdit.tsx new file mode 100644 index 00000000..71f5ce60 --- /dev/null +++ b/packages/devtools/src/components/ChatMessage/MessageUpdate/ChatMessageEdit.tsx @@ -0,0 +1,36 @@ +import { FC, memo } from 'react'; +import { Message } from '@microsoft/spark.api'; +import { useLocation } from 'react-router'; + +import { useCardStore } from '../../../stores/CardStore'; +import EditComposeBox from '../../ComposeBox/EditComposeBox'; + +interface ChatMessageEditProps { + message: Message; + onEditComplete: (messageId: string, content: string, attachments: any[]) => void; + onEditCancel: () => void; + isUpdating: boolean; + onCardProcessed?: () => void; +} + +const ChatMessageEdit: FC = memo( + ({ message, onEditComplete, onEditCancel, onCardProcessed }) => { + const location = useLocation(); + const { targetComponent, draftMessage } = useCardStore(); + const fromCards = location.state?.isEditing && targetComponent === 'edit'; + + return ( + onEditComplete(message.id, content, attachments)} + onCancel={onEditCancel} + onCardProcessed={onCardProcessed} + /> + ); + } +); + +ChatMessageEdit.displayName = 'ChatMessageEdit'; +export default ChatMessageEdit; diff --git a/packages/devtools/src/components/ComposeBox/ComposeBox.styles.ts b/packages/devtools/src/components/ComposeBox/ComposeBox.styles.ts index f2e9e57e..7530b8e5 100644 --- a/packages/devtools/src/components/ComposeBox/ComposeBox.styles.ts +++ b/packages/devtools/src/components/ComposeBox/ComposeBox.styles.ts @@ -1,22 +1,25 @@ -import { makeStyles } from '@fluentui/react-components'; +import { makeStyles, tokens } from '@fluentui/react-components'; const useComposeBoxClasses = makeStyles({ composeBoxContainer: { - position: 'relative', margin: '1rem 3.125rem', width: 'auto', - display: 'flex', - flexDirection: 'column', }, composeInput: { width: '100%', - padding: '10px 0', - paddingRight: '100px', }, - textareaContainer: { - display: 'flex', - flexDirection: 'column', - position: 'relative', + + error: { + border: `${tokens.strokeWidthThin} solid ${tokens.colorPaletteRedBorder2}`, + '&:focus-within': { + border: `${tokens.strokeWidthThin} solid ${tokens.colorPaletteRedBorder2}`, + }, + }, + errorMessage: { + color: tokens.colorPaletteRedForeground1, + fontSize: tokens.fontSizeBase200, + marginTop: tokens.spacingVerticalXXS, + marginBottom: tokens.spacingVerticalXXS, }, }); diff --git a/packages/devtools/src/components/ComposeBox/ComposeBox.tsx b/packages/devtools/src/components/ComposeBox/ComposeBox.tsx index 34a7e862..d6346b5d 100644 --- a/packages/devtools/src/components/ComposeBox/ComposeBox.tsx +++ b/packages/devtools/src/components/ComposeBox/ComposeBox.tsx @@ -1,231 +1,260 @@ import { FC, - useState, - ChangeEvent, + FormEvent, KeyboardEvent, - useRef, - useEffect, + memo, useCallback, + useEffect, + useLayoutEffect, useMemo, + useRef, + useState, } from 'react'; -import { Textarea } from '@fluentui/react-components'; import { Attachment, Message } from '@microsoft/spark.api'; import { useCardStore } from '../../stores/CardStore'; -import { AttachmentType } from '../../types/Attachment'; -import Logger from '../Logger/Logger'; import AttachmentsContainer from '../AttachmentsContainer/AttachmentsContainer'; -import NewMessageToolbar from './ComposeBoxToolbar/ComposeBoxToolbar'; +import ContentEditableArea from '../ContentEditableArea/ContentEditableArea'; +import Logger from '../Logger/Logger'; + +import ComposeBoxToolbar from './ComposeBoxToolbar/ComposeBoxToolbar'; import useComposeBoxClasses from './ComposeBox.styles'; +import { processMessageContent, convertAttachmentsForUI } from './ComposeBoxUtils'; export interface ComposeBoxProps { onSend: (message: Partial, attachments?: Attachment[]) => void; messageHistory: Partial[]; onMessageSent: (message: Partial) => void; + onCardProcessed?: () => void; + disabled?: boolean; } -const ComposeBox: FC = ({ onSend, messageHistory, onMessageSent }) => { - const classes = useComposeBoxClasses(); - const [message, setMessage] = useState(''); - const [attachments, setAttachments] = useState([]); - const [uiAttachments, setUiAttachments] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - const textareaRef = useRef(null); - const { currentCard, clearCurrentCard } = useCardStore(); - const processedCardRef = useRef(null); - const childLog = Logger.child('ComposeBox'); - - useEffect(() => { - if (textareaRef.current) { - textareaRef.current.focus(); - } - }, []); - - // Convert API Attachment to UI AttachmentType - const convertToAttachmentType = (attachment: Attachment): AttachmentType => { - // Check for card attachment - if (attachment.contentType?.startsWith('application/vnd.microsoft.card.')) { - return { - type: 'card', - content: attachment.content, - name: attachment.name, - }; - } - - // Handle image attachments - if (attachment.contentType?.startsWith('image/')) { - return { - type: 'image', - content: attachment.contentUrl || attachment.content, - name: attachment.name, - }; - } - - // Handle other file attachments - return { - type: 'file', - content: attachment.contentUrl || attachment.content, - name: attachment.name, - }; - }; - - // Update UI attachments when API attachments change - useEffect(() => { - setUiAttachments(attachments.map(convertToAttachmentType)); - }, [attachments]); - - // Process currentCard only once when it changes - useEffect(() => { - if (currentCard && JSON.stringify(processedCardRef.current) !== JSON.stringify(currentCard)) { - childLog.info('Processing new card from CardStore:', currentCard); - processedCardRef.current = currentCard; - - const newAttachment: Attachment = { - contentType: 'application/vnd.microsoft.card.adaptive', - content: currentCard, +const childLog = Logger.child('ComposeBox'); + +const ComposeBox: FC = memo( + ({ onSend, messageHistory, onMessageSent, onCardProcessed, disabled = false }) => { + const classes = useComposeBoxClasses(); + const { + currentCard, + targetComponent, + processedCardIds, + draftMessage, + addProcessedCardId, + clearCurrentCard, + clearProcessedCardIds, + setCurrentCard, + setDraftMessage, + } = useCardStore(); + + const [message, setMessage] = useState(''); + const [attachments, setAttachments] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const contentEditableRef = useRef(null); + const mountedRef = useRef(false); + + // Focus on input and mark as mounted when component mounts + useEffect(() => { + if (contentEditableRef.current) { + contentEditableRef.current.focus(); + } + mountedRef.current = true; + return () => { + mountedRef.current = false; }; + }, []); - setAttachments((prev) => [...prev, newAttachment]); - - // Clear the current card from the store - clearCurrentCard(); - } - }, [currentCard, clearCurrentCard, childLog]); - - // Handle sending message with text and attachments - const handleSendMessage = useCallback(() => { - if (message.trim() || attachments.length > 0) { - const messageObj: Partial = { - body: { - content: message, - contentType: 'text', - }, - attachments, + // Set as active component when focused + useEffect(() => { + const handleFocus = () => { + if (currentCard && mountedRef.current) { + setCurrentCard(currentCard, 'compose'); + } }; - onSend(messageObj); - if (message.trim()) { - onMessageSent(messageObj); + + const composeBox = contentEditableRef.current; + if (composeBox) { + composeBox.addEventListener('focus', handleFocus); + return () => { + composeBox.removeEventListener('focus', handleFocus); + }; } - setMessage(''); - setAttachments([]); - setHistoryIndex(-1); - processedCardRef.current = null; - } - }, [message, attachments, onSend, onMessageSent]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } else if (e.key === 'ArrowUp' && !e.shiftKey && (message === '' || historyIndex !== -1)) { - e.preventDefault(); - if (messageHistory.length > 0) { - const newIndex = - historyIndex === -1 ? 0 : Math.min(historyIndex + 1, messageHistory.length - 1); - if (newIndex < messageHistory.length) { + }, [currentCard, setCurrentCard]); + + useLayoutEffect(() => { + if (mountedRef.current && draftMessage && targetComponent !== 'edit') { + setMessage(draftMessage); + } + }, [draftMessage, targetComponent]); + + // Process currentCard only once when it changes and when this component is the target + useEffect(() => { + if (currentCard && targetComponent !== 'edit' && mountedRef.current) { + childLog.info('Logging card to CardStore'); + + const currentCardStr = JSON.stringify(currentCard); + + if (!processedCardIds.has(currentCardStr)) { + childLog.info('Processing new card in CardStore'); + + const newAttachment: Attachment = { + contentType: 'application/vnd.microsoft.card.adaptive', + content: currentCard, + }; + + setAttachments((prev) => { + // Don't add the attachment if it already exists + if (prev.some((a) => JSON.stringify(a.content) === currentCardStr)) { + childLog.info('Card from CardStore already exists in attachments, skipping'); + return prev; + } + + addProcessedCardId(currentCardStr); + onCardProcessed?.(); + return [...prev, newAttachment]; + }); + } else { + childLog.info('Card already processed, skipping'); + } + } + }, [currentCard, targetComponent, processedCardIds, addProcessedCardId, onCardProcessed]); + + const handleSendMessage = useCallback(() => { + const trimmedMessage = message.trim(); + if (trimmedMessage || attachments.length > 0) { + const messageObj: Partial = { + body: { + content: trimmedMessage, + contentType: 'text', + }, + attachments, + }; + onSend(messageObj); + setDraftMessage(); + if (trimmedMessage) { + onMessageSent(messageObj); + } + setMessage(''); + setAttachments([]); + setHistoryIndex(-1); + + // Clear card state + clearCurrentCard(); + clearProcessedCardIds(); + } + }, [ + attachments, + clearCurrentCard, + clearProcessedCardIds, + message, + onMessageSent, + onSend, + setDraftMessage, + ]); + + const handleInputChange = useCallback( + (e: FormEvent) => { + if (disabled) return; + const target = e.target as HTMLDivElement; + setMessage(processMessageContent(target.innerHTML)); + }, + [disabled] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + if (!e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + } else if (e.key === 'ArrowUp' && !e.shiftKey && (message === '' || historyIndex !== -1)) { + e.preventDefault(); + if (messageHistory.length > 0) { + const newIndex = + historyIndex === -1 ? 0 : Math.min(historyIndex + 1, messageHistory.length - 1); + if (newIndex < messageHistory.length) { + setHistoryIndex(newIndex); + const historyMessage = messageHistory[newIndex]; + setMessage(historyMessage.body?.content || ''); + if (historyMessage.attachments) { + setAttachments(historyMessage.attachments); + } + } + } + } else if (e.key === 'ArrowDown' && !e.shiftKey && historyIndex !== -1) { + e.preventDefault(); + const newIndex = historyIndex - 1; + if (newIndex >= 0) { setHistoryIndex(newIndex); const historyMessage = messageHistory[newIndex]; setMessage(historyMessage.body?.content || ''); if (historyMessage.attachments) { setAttachments(historyMessage.attachments); } + } else { + setHistoryIndex(-1); + setMessage(''); + setAttachments([]); } } - } else if (e.key === 'ArrowDown' && !e.shiftKey && historyIndex !== -1) { - e.preventDefault(); - const newIndex = historyIndex - 1; - if (newIndex >= 0) { - setHistoryIndex(newIndex); - const historyMessage = messageHistory[newIndex]; - setMessage(historyMessage.body?.content || ''); - if (historyMessage.attachments) { - setAttachments(historyMessage.attachments); - } - } else { - setHistoryIndex(-1); - setMessage(''); - setAttachments([]); - } - } - }, - [handleSendMessage, message, historyIndex, messageHistory] - ); - - // Handle toolbar actions - const handleToolbarAction = useCallback( - (toolbarAttachments?: any[]) => { - if (toolbarAttachments && toolbarAttachments.length > 0) { - childLog.info('Processing attachments from toolbar:', toolbarAttachments); - - // If we have new attachments, add them directly - const newAttachments: Attachment[] = toolbarAttachments.map((attachment) => ({ - contentType: - attachment.type === 'card' - ? 'application/vnd.microsoft.card.adaptive' - : attachment.type === 'image' - ? 'image/png' - : 'application/octet-stream', - content: attachment.content, - name: attachment.name, - })); - - // Add attachments directly without checking for duplicates - // This is safe because we're handling toolbar actions directly - setAttachments((prev) => [...prev, ...newAttachments]); - } else { - // If no attachments, this is a send action - // Only proceed if there's text content or existing attachments - if (message.trim() || attachments.length > 0) { - handleSendMessage(); - } - } - }, - [childLog, handleSendMessage, message, attachments] - ); - - const handleRemoveAttachment = useCallback( - (index: number) => { - const newAttachments = [...attachments]; - newAttachments.splice(index, 1); - setAttachments(newAttachments); - }, - [attachments] - ); - - // Memoized message input handler to prevent re-renders - const handleMessageChange = useCallback((e: ChangeEvent) => { - setMessage(e.target.value); - }, []); - - // Check if there's content to send - const hasContent = message.trim().length > 0 || attachments.length > 0; - - const memoizedToolbar = useMemo( - () => , - [handleToolbarAction, hasContent] - ); - - return ( -
-