diff --git a/docs/data/chat/behavior/error-handling/error-handling.md b/docs/data/chat/behavior/error-handling/error-handling.md index 60785f4624100..1740311f1f17c 100644 --- a/docs/data/chat/behavior/error-handling/error-handling.md +++ b/docs/data/chat/behavior/error-handling/error-handling.md @@ -3,7 +3,7 @@ productId: x-chat title: Error Handling packageName: '@mui/x-chat' githubLabel: 'scope: chat' -components: ChatBox, MessageError +components: ChatBox, ChatMessageError, MessageError --- # Chat - Error Handling diff --git a/docs/data/chatApiPages.ts b/docs/data/chatApiPages.ts index 7be6ed1fbe5a2..1e642f3f8b5a3 100644 --- a/docs/data/chatApiPages.ts +++ b/docs/data/chatApiPages.ts @@ -93,6 +93,10 @@ const chatApiPages: MuiPage[] = [ pathname: '/x/api/chat/chat-message-content', title: 'ChatMessageContent', }, + { + pathname: '/x/api/chat/chat-message-error', + title: 'ChatMessageError', + }, { pathname: '/x/api/chat/chat-message-group', title: 'ChatMessageGroup', diff --git a/docs/pages/x/api/chat/chat-date-divider.json b/docs/pages/x/api/chat/chat-date-divider.json index 8a2355526b6e5..821827e8ddf06 100644 --- a/docs/pages/x/api/chat/chat-date-divider.json +++ b/docs/pages/x/api/chat/chat-date-divider.json @@ -84,6 +84,12 @@ "description": "Styles applied to the message meta element.", "isGlobal": false }, + { + "key": "noAvatar", + "className": "MuiChatDateDivider-noAvatar", + "description": "Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track.", + "isGlobal": false + }, { "key": "roleAssistant", "className": "MuiChatDateDivider-roleAssistant", diff --git a/docs/pages/x/api/chat/chat-layout.json b/docs/pages/x/api/chat/chat-layout.json index 3bbbecbb69847..b48503f9a68bc 100644 --- a/docs/pages/x/api/chat/chat-layout.json +++ b/docs/pages/x/api/chat/chat-layout.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ChatLayout", - "imports": [ - "import { ChatLayout } from '@mui/x-chat/headless';", - "import { ChatLayout } from '@mui/x-chat/headless';" - ], + "imports": ["import { ChatLayout } from '@mui/x-chat/headless';"], "slots": [ { "name": "root", "description": "", "class": null }, { "name": "conversationsPane", "description": "", "class": null }, diff --git a/docs/pages/x/api/chat/chat-message-actions.json b/docs/pages/x/api/chat/chat-message-actions.json index c1d3e17f02c8c..2cd644b7d6905 100644 --- a/docs/pages/x/api/chat/chat-message-actions.json +++ b/docs/pages/x/api/chat/chat-message-actions.json @@ -84,6 +84,12 @@ "description": "Styles applied to the message meta element.", "isGlobal": false }, + { + "key": "noAvatar", + "className": "MuiChatMessageActions-noAvatar", + "description": "Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track.", + "isGlobal": false + }, { "key": "roleAssistant", "className": "MuiChatMessageActions-roleAssistant", diff --git a/docs/pages/x/api/chat/chat-message-author-label.json b/docs/pages/x/api/chat/chat-message-author-label.json index 8a7898292481a..2cb45a2b755f2 100644 --- a/docs/pages/x/api/chat/chat-message-author-label.json +++ b/docs/pages/x/api/chat/chat-message-author-label.json @@ -84,6 +84,12 @@ "description": "Styles applied to the message meta element.", "isGlobal": false }, + { + "key": "noAvatar", + "className": "MuiChatMessageAuthorLabel-noAvatar", + "description": "Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track.", + "isGlobal": false + }, { "key": "roleAssistant", "className": "MuiChatMessageAuthorLabel-roleAssistant", diff --git a/docs/pages/x/api/chat/chat-message-avatar.json b/docs/pages/x/api/chat/chat-message-avatar.json index 6dd7f3b4ca567..241c4b8958d35 100644 --- a/docs/pages/x/api/chat/chat-message-avatar.json +++ b/docs/pages/x/api/chat/chat-message-avatar.json @@ -84,6 +84,12 @@ "description": "Styles applied to the message meta element.", "isGlobal": false }, + { + "key": "noAvatar", + "className": "MuiChatMessageAvatar-noAvatar", + "description": "Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track.", + "isGlobal": false + }, { "key": "roleAssistant", "className": "MuiChatMessageAvatar-roleAssistant", diff --git a/docs/pages/x/api/chat/chat-message-content.json b/docs/pages/x/api/chat/chat-message-content.json index 5916c026a1125..e67928d155e69 100644 --- a/docs/pages/x/api/chat/chat-message-content.json +++ b/docs/pages/x/api/chat/chat-message-content.json @@ -92,6 +92,12 @@ "description": "Styles applied to the message meta element.", "isGlobal": false }, + { + "key": "noAvatar", + "className": "MuiChatMessageContent-noAvatar", + "description": "Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track.", + "isGlobal": false + }, { "key": "roleAssistant", "className": "MuiChatMessageContent-roleAssistant", diff --git a/docs/pages/x/api/chat/chat-message-error.js b/docs/pages/x/api/chat/chat-message-error.js new file mode 100644 index 0000000000000..7a922e5a86803 --- /dev/null +++ b/docs/pages/x/api/chat/chat-message-error.js @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslations } from '@mui/internal-core-docs/mapApiPageTranslations'; +import jsonPageContent from './chat-message-error.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const req = require.context( + 'docs/translations/api-docs/chat/chat-message-error', + false, + /\.\/chat-message-error.*\.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { props: { descriptions } }; +} diff --git a/docs/pages/x/api/chat/chat-message-error.json b/docs/pages/x/api/chat/chat-message-error.json new file mode 100644 index 0000000000000..303a3ca4da464 --- /dev/null +++ b/docs/pages/x/api/chat/chat-message-error.json @@ -0,0 +1,35 @@ +{ + "props": {}, + "name": "ChatMessageError", + "imports": [ + "import { ChatMessageError } from '@mui/x-chat/ChatMessageError';", + "import { ChatMessageError } from '@mui/x-chat';" + ], + "classes": [ + { + "key": "message", + "className": "MuiChatMessageError-message", + "description": "Styles applied to the message text element.", + "isGlobal": false + }, + { + "key": "retryButton", + "className": "MuiChatMessageError-retryButton", + "description": "Styles applied to the retry button.", + "isGlobal": false + }, + { + "key": "root", + "className": "MuiChatMessageError-root", + "description": "Styles applied to the root element.", + "isGlobal": false + } + ], + "spread": true, + "themeDefaultProps": null, + "muiName": "MuiChatMessageError", + "filename": "/packages/x-chat/src/ChatMessageError/ChatMessageError.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/x/api/chat/chat-message-group.json b/docs/pages/x/api/chat/chat-message-group.json index 2cb1aa276604b..cbf3752207d53 100644 --- a/docs/pages/x/api/chat/chat-message-group.json +++ b/docs/pages/x/api/chat/chat-message-group.json @@ -89,6 +89,12 @@ "description": "Styles applied to the message meta element.", "isGlobal": false }, + { + "key": "noAvatar", + "className": "MuiChatMessageGroup-noAvatar", + "description": "Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track.", + "isGlobal": false + }, { "key": "roleAssistant", "className": "MuiChatMessageGroup-roleAssistant", diff --git a/docs/pages/x/api/chat/chat-message-list.json b/docs/pages/x/api/chat/chat-message-list.json index 60189bfe5ab7d..5098580b4beec 100644 --- a/docs/pages/x/api/chat/chat-message-list.json +++ b/docs/pages/x/api/chat/chat-message-list.json @@ -3,7 +3,8 @@ "autoScroll": { "type": { "name": "union", "description": "{ buffer?: number }
| bool" }, "default": "true" - } + }, + "renderItem": { "type": { "name": "func" } } }, "name": "ChatMessageList", "imports": [ diff --git a/docs/pages/x/api/chat/chat-message-meta.json b/docs/pages/x/api/chat/chat-message-meta.json index 6c1f4de0d34a2..8f14f47382de1 100644 --- a/docs/pages/x/api/chat/chat-message-meta.json +++ b/docs/pages/x/api/chat/chat-message-meta.json @@ -84,6 +84,12 @@ "description": "Styles applied to the message meta element.", "isGlobal": false }, + { + "key": "noAvatar", + "className": "MuiChatMessageMeta-noAvatar", + "description": "Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track.", + "isGlobal": false + }, { "key": "roleAssistant", "className": "MuiChatMessageMeta-roleAssistant", diff --git a/docs/pages/x/api/chat/chat-root.json b/docs/pages/x/api/chat/chat-root.json index 464de68732ea3..3ca04e0527522 100644 --- a/docs/pages/x/api/chat/chat-root.json +++ b/docs/pages/x/api/chat/chat-root.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ChatRoot", - "imports": [ - "import { ChatRoot } from '@mui/x-chat/headless';", - "import { ChatRoot } from '@mui/x-chat/headless';" - ], + "imports": ["import { ChatRoot } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "spread": true, diff --git a/docs/pages/x/api/chat/composer-attach-button.json b/docs/pages/x/api/chat/composer-attach-button.json index 2466bf574a0ca..c965c2bb896c7 100644 --- a/docs/pages/x/api/chat/composer-attach-button.json +++ b/docs/pages/x/api/chat/composer-attach-button.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerAttachButton", - "imports": [ - "import { ComposerAttachButton } from '@mui/x-chat/headless';", - "import { ComposerAttachButton } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerAttachButton } from '@mui/x-chat/headless';"], "slots": [ { "name": "attachButton", "description": "", "class": null }, { "name": "attachInput", "description": "", "class": null } diff --git a/docs/pages/x/api/chat/composer-attachment-list.json b/docs/pages/x/api/chat/composer-attachment-list.json index 64cd2611ffdb6..478c3b23d96d4 100644 --- a/docs/pages/x/api/chat/composer-attachment-list.json +++ b/docs/pages/x/api/chat/composer-attachment-list.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerAttachmentList", - "imports": [ - "import { ComposerAttachmentList } from '@mui/x-chat/headless';", - "import { ComposerAttachmentList } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerAttachmentList } from '@mui/x-chat/headless';"], "slots": [{ "name": "attachmentList", "description": "", "class": null }], "classes": [], "muiName": "MuiComposerAttachmentList", diff --git a/docs/pages/x/api/chat/composer-helper-text.json b/docs/pages/x/api/chat/composer-helper-text.json index e8ad93a0e27cb..21f6de630fded 100644 --- a/docs/pages/x/api/chat/composer-helper-text.json +++ b/docs/pages/x/api/chat/composer-helper-text.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerHelperText", - "imports": [ - "import { ComposerHelperText } from '@mui/x-chat/headless';", - "import { ComposerHelperText } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerHelperText } from '@mui/x-chat/headless';"], "slots": [{ "name": "helperText", "description": "", "class": null }], "classes": [], "muiName": "MuiComposerHelperText", diff --git a/docs/pages/x/api/chat/composer-label.json b/docs/pages/x/api/chat/composer-label.json index 83878aae0b66d..bc72084e82450 100644 --- a/docs/pages/x/api/chat/composer-label.json +++ b/docs/pages/x/api/chat/composer-label.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerLabel", - "imports": [ - "import { ComposerLabel } from '@mui/x-chat/headless';", - "import { ComposerLabel } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerLabel } from '@mui/x-chat/headless';"], "slots": [{ "name": "label", "description": "", "class": null }], "classes": [], "muiName": "MuiComposerLabel", diff --git a/docs/pages/x/api/chat/composer-root.json b/docs/pages/x/api/chat/composer-root.json index 58a093e91668f..5891c3458e532 100644 --- a/docs/pages/x/api/chat/composer-root.json +++ b/docs/pages/x/api/chat/composer-root.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerRoot", - "imports": [ - "import { ComposerRoot } from '@mui/x-chat/headless';", - "import { ComposerRoot } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerRoot } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "spread": true, diff --git a/docs/pages/x/api/chat/composer-send-button.json b/docs/pages/x/api/chat/composer-send-button.json index 1e7a7c7edb497..0a1df48d5235e 100644 --- a/docs/pages/x/api/chat/composer-send-button.json +++ b/docs/pages/x/api/chat/composer-send-button.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerSendButton", - "imports": [ - "import { ComposerSendButton } from '@mui/x-chat/headless';", - "import { ComposerSendButton } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerSendButton } from '@mui/x-chat/headless';"], "slots": [{ "name": "sendButton", "description": "", "class": null }], "classes": [], "muiName": "MuiComposerSendButton", diff --git a/docs/pages/x/api/chat/composer-text-area.json b/docs/pages/x/api/chat/composer-text-area.json index 6a8e0dfb8c9d9..b34ad359419fe 100644 --- a/docs/pages/x/api/chat/composer-text-area.json +++ b/docs/pages/x/api/chat/composer-text-area.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerTextArea", - "imports": [ - "import { ComposerTextArea } from '@mui/x-chat/headless';", - "import { ComposerTextArea } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerTextArea } from '@mui/x-chat/headless';"], "slots": [{ "name": "input", "description": "", "class": null }], "classes": [], "muiName": "MuiComposerTextArea", diff --git a/docs/pages/x/api/chat/composer-toolbar.json b/docs/pages/x/api/chat/composer-toolbar.json index e16077b187f18..300c70fa9dd82 100644 --- a/docs/pages/x/api/chat/composer-toolbar.json +++ b/docs/pages/x/api/chat/composer-toolbar.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ComposerToolbar", - "imports": [ - "import { ComposerToolbar } from '@mui/x-chat/headless';", - "import { ComposerToolbar } from '@mui/x-chat/headless';" - ], + "imports": ["import { ComposerToolbar } from '@mui/x-chat/headless';"], "slots": [{ "name": "toolbar", "description": "", "class": null }], "classes": [], "muiName": "MuiComposerToolbar", diff --git a/docs/pages/x/api/chat/conversation-header-actions.json b/docs/pages/x/api/chat/conversation-header-actions.json index fbfa4a81024d7..bf79dbc6485da 100644 --- a/docs/pages/x/api/chat/conversation-header-actions.json +++ b/docs/pages/x/api/chat/conversation-header-actions.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationHeaderActions", - "imports": [ - "import { ConversationHeaderActions } from '@mui/x-chat/headless';", - "import { ConversationHeaderActions } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationHeaderActions } from '@mui/x-chat/headless';"], "slots": [{ "name": "actions", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationHeaderActions", diff --git a/docs/pages/x/api/chat/conversation-header.json b/docs/pages/x/api/chat/conversation-header.json index 3ae09c616ac91..182a33706044f 100644 --- a/docs/pages/x/api/chat/conversation-header.json +++ b/docs/pages/x/api/chat/conversation-header.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationHeader", - "imports": [ - "import { ConversationHeader } from '@mui/x-chat/headless';", - "import { ConversationHeader } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationHeader } from '@mui/x-chat/headless';"], "slots": [{ "name": "header", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationHeader", diff --git a/docs/pages/x/api/chat/conversation-list-item-avatar.json b/docs/pages/x/api/chat/conversation-list-item-avatar.json index 010e555f108ad..c935ec422e791 100644 --- a/docs/pages/x/api/chat/conversation-list-item-avatar.json +++ b/docs/pages/x/api/chat/conversation-list-item-avatar.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationListItemAvatar", - "imports": [ - "import { ConversationListItemAvatar } from '@mui/x-chat/headless';", - "import { ConversationListItemAvatar } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationListItemAvatar } from '@mui/x-chat/headless';"], "slots": [ { "name": "root", "description": "", "class": null }, { "name": "image", "description": "", "class": null } diff --git a/docs/pages/x/api/chat/conversation-list-item.json b/docs/pages/x/api/chat/conversation-list-item.json index 50706a61a31dc..bbc539208cb94 100644 --- a/docs/pages/x/api/chat/conversation-list-item.json +++ b/docs/pages/x/api/chat/conversation-list-item.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationListItem", - "imports": [ - "import { ConversationListItem } from '@mui/x-chat/headless';", - "import { ConversationListItem } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationListItem } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationListItem", diff --git a/docs/pages/x/api/chat/conversation-list-preview.json b/docs/pages/x/api/chat/conversation-list-preview.json index 398eacc63a949..b72edbf0503e4 100644 --- a/docs/pages/x/api/chat/conversation-list-preview.json +++ b/docs/pages/x/api/chat/conversation-list-preview.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationListPreview", - "imports": [ - "import { ConversationListPreview } from '@mui/x-chat/headless';", - "import { ConversationListPreview } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationListPreview } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationListPreview", diff --git a/docs/pages/x/api/chat/conversation-list-root.json b/docs/pages/x/api/chat/conversation-list-root.json index 5384ecc613f0f..9f965a61ac85c 100644 --- a/docs/pages/x/api/chat/conversation-list-root.json +++ b/docs/pages/x/api/chat/conversation-list-root.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationListRoot", - "imports": [ - "import { ConversationListRoot } from '@mui/x-chat/headless';", - "import { ConversationListRoot } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationListRoot } from '@mui/x-chat/headless';"], "slots": [ { "name": "root", "description": "", "class": null }, { "name": "scroller", "description": "", "class": null }, diff --git a/docs/pages/x/api/chat/conversation-list-timestamp.json b/docs/pages/x/api/chat/conversation-list-timestamp.json index 61dfa84816864..0a5dfa6a4196e 100644 --- a/docs/pages/x/api/chat/conversation-list-timestamp.json +++ b/docs/pages/x/api/chat/conversation-list-timestamp.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationListTimestamp", - "imports": [ - "import { ConversationListTimestamp } from '@mui/x-chat/headless';", - "import { ConversationListTimestamp } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationListTimestamp } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationListTimestamp", diff --git a/docs/pages/x/api/chat/conversation-list-title.json b/docs/pages/x/api/chat/conversation-list-title.json index 99061daed53b2..cdd1e7528fb12 100644 --- a/docs/pages/x/api/chat/conversation-list-title.json +++ b/docs/pages/x/api/chat/conversation-list-title.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationListTitle", - "imports": [ - "import { ConversationListTitle } from '@mui/x-chat/headless';", - "import { ConversationListTitle } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationListTitle } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationListTitle", diff --git a/docs/pages/x/api/chat/conversation-list-unread-badge.json b/docs/pages/x/api/chat/conversation-list-unread-badge.json index c78c610000432..ad72cb8484f99 100644 --- a/docs/pages/x/api/chat/conversation-list-unread-badge.json +++ b/docs/pages/x/api/chat/conversation-list-unread-badge.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationListUnreadBadge", - "imports": [ - "import { ConversationListUnreadBadge } from '@mui/x-chat/headless';", - "import { ConversationListUnreadBadge } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationListUnreadBadge } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationListUnreadBadge", diff --git a/docs/pages/x/api/chat/conversation-root.json b/docs/pages/x/api/chat/conversation-root.json index 89b0088ef27f1..a41fd073895fd 100644 --- a/docs/pages/x/api/chat/conversation-root.json +++ b/docs/pages/x/api/chat/conversation-root.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationRoot", - "imports": [ - "import { ConversationRoot } from '@mui/x-chat/headless';", - "import { ConversationRoot } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationRoot } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "spread": true, diff --git a/docs/pages/x/api/chat/conversation-subtitle.json b/docs/pages/x/api/chat/conversation-subtitle.json index 5309a6b0c09f3..e535ddb1f5b75 100644 --- a/docs/pages/x/api/chat/conversation-subtitle.json +++ b/docs/pages/x/api/chat/conversation-subtitle.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationSubtitle", - "imports": [ - "import { ConversationSubtitle } from '@mui/x-chat/headless';", - "import { ConversationSubtitle } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationSubtitle } from '@mui/x-chat/headless';"], "slots": [{ "name": "subtitle", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationSubtitle", diff --git a/docs/pages/x/api/chat/conversation-title.json b/docs/pages/x/api/chat/conversation-title.json index dd3c0537b72e1..6a00db7b6307e 100644 --- a/docs/pages/x/api/chat/conversation-title.json +++ b/docs/pages/x/api/chat/conversation-title.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ConversationTitle", - "imports": [ - "import { ConversationTitle } from '@mui/x-chat/headless';", - "import { ConversationTitle } from '@mui/x-chat/headless';" - ], + "imports": ["import { ConversationTitle } from '@mui/x-chat/headless';"], "slots": [{ "name": "title", "description": "", "class": null }], "classes": [], "muiName": "MuiConversationTitle", diff --git a/docs/pages/x/api/chat/message-actions.json b/docs/pages/x/api/chat/message-actions.json index 6ed07a6600b9d..0af0e3a8322e0 100644 --- a/docs/pages/x/api/chat/message-actions.json +++ b/docs/pages/x/api/chat/message-actions.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageActions", - "imports": [ - "import { MessageActions } from '@mui/x-chat/headless';", - "import { MessageActions } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageActions } from '@mui/x-chat/headless';"], "slots": [{ "name": "actions", "description": "", "class": null }], "classes": [], "muiName": "MuiMessageActions", diff --git a/docs/pages/x/api/chat/message-author-label.json b/docs/pages/x/api/chat/message-author-label.json index b5662b2a21242..048a9a28a2892 100644 --- a/docs/pages/x/api/chat/message-author-label.json +++ b/docs/pages/x/api/chat/message-author-label.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageAuthorLabel", - "imports": [ - "import { MessageAuthorLabel } from '@mui/x-chat/headless';", - "import { MessageAuthorLabel } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageAuthorLabel } from '@mui/x-chat/headless';"], "slots": [{ "name": "authorLabel", "description": "", "class": null }], "classes": [], "muiName": "MuiMessageAuthorLabel", diff --git a/docs/pages/x/api/chat/message-avatar.json b/docs/pages/x/api/chat/message-avatar.json index 8bfbfa8c1f9bc..6d8a8a45439f6 100644 --- a/docs/pages/x/api/chat/message-avatar.json +++ b/docs/pages/x/api/chat/message-avatar.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageAvatar", - "imports": [ - "import { MessageAvatar } from '@mui/x-chat/headless';", - "import { MessageAvatar } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageAvatar } from '@mui/x-chat/headless';"], "slots": [ { "name": "avatar", "description": "", "class": null }, { "name": "image", "description": "", "class": null } diff --git a/docs/pages/x/api/chat/message-content.json b/docs/pages/x/api/chat/message-content.json index 9c51797be4e60..e7f7f76a7c07d 100644 --- a/docs/pages/x/api/chat/message-content.json +++ b/docs/pages/x/api/chat/message-content.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageContent", - "imports": [ - "import { MessageContent } from '@mui/x-chat/headless';", - "import { MessageContent } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageContent } from '@mui/x-chat/headless';"], "slots": [ { "name": "content", "description": "", "class": null }, { "name": "bubble", "description": "", "class": null } diff --git a/docs/pages/x/api/chat/message-error.json b/docs/pages/x/api/chat/message-error.json index fbf0e50c4bb31..01ebb043a30a7 100644 --- a/docs/pages/x/api/chat/message-error.json +++ b/docs/pages/x/api/chat/message-error.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageError", - "imports": [ - "import { MessageError } from '@mui/x-chat/headless';", - "import { MessageError } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageError } from '@mui/x-chat/headless';"], "slots": [ { "name": "root", diff --git a/docs/pages/x/api/chat/message-group.json b/docs/pages/x/api/chat/message-group.json index bf9bf23d673f2..ded81f3d58572 100644 --- a/docs/pages/x/api/chat/message-group.json +++ b/docs/pages/x/api/chat/message-group.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageGroup", - "imports": [ - "import { MessageGroup } from '@mui/x-chat/headless';", - "import { MessageGroup } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageGroup } from '@mui/x-chat/headless';"], "slots": [ { "name": "group", "description": "", "class": null }, { "name": "authorName", "description": "", "class": null }, diff --git a/docs/pages/x/api/chat/message-list-date-divider.json b/docs/pages/x/api/chat/message-list-date-divider.json index 4a8ad547381a3..21815908ac841 100644 --- a/docs/pages/x/api/chat/message-list-date-divider.json +++ b/docs/pages/x/api/chat/message-list-date-divider.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageListDateDivider", - "imports": [ - "import { MessageListDateDivider } from '@mui/x-chat/headless';", - "import { MessageListDateDivider } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageListDateDivider } from '@mui/x-chat/headless';"], "slots": [ { "name": "divider", "description": "", "class": null }, { "name": "line", "description": "", "class": null }, diff --git a/docs/pages/x/api/chat/message-list-root.json b/docs/pages/x/api/chat/message-list-root.json index 776c3f5c4bca4..5619464afb401 100644 --- a/docs/pages/x/api/chat/message-list-root.json +++ b/docs/pages/x/api/chat/message-list-root.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageListRoot", - "imports": [ - "import { MessageListRoot } from '@mui/x-chat/headless';", - "import { MessageListRoot } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageListRoot } from '@mui/x-chat/headless';"], "slots": [ { "name": "messageList", "description": "", "class": null }, { "name": "messageListScroller", "description": "", "class": null }, diff --git a/docs/pages/x/api/chat/message-meta.json b/docs/pages/x/api/chat/message-meta.json index 42d88ace297bf..8486eaa456244 100644 --- a/docs/pages/x/api/chat/message-meta.json +++ b/docs/pages/x/api/chat/message-meta.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageMeta", - "imports": [ - "import { MessageMeta } from '@mui/x-chat/headless';", - "import { MessageMeta } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageMeta } from '@mui/x-chat/headless';"], "slots": [ { "name": "meta", "description": "", "class": null }, { "name": "timestamp", "description": "", "class": null }, diff --git a/docs/pages/x/api/chat/message-root.json b/docs/pages/x/api/chat/message-root.json index 9c475410921ee..10a9fb0d26103 100644 --- a/docs/pages/x/api/chat/message-root.json +++ b/docs/pages/x/api/chat/message-root.json @@ -1,10 +1,7 @@ { "props": {}, "name": "MessageRoot", - "imports": [ - "import { MessageRoot } from '@mui/x-chat/headless';", - "import { MessageRoot } from '@mui/x-chat/headless';" - ], + "imports": ["import { MessageRoot } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "spread": true, diff --git a/docs/pages/x/api/chat/scroll-to-bottom-affordance.json b/docs/pages/x/api/chat/scroll-to-bottom-affordance.json index aa31c292de12d..52082816caf1c 100644 --- a/docs/pages/x/api/chat/scroll-to-bottom-affordance.json +++ b/docs/pages/x/api/chat/scroll-to-bottom-affordance.json @@ -1,10 +1,7 @@ { "props": {}, "name": "ScrollToBottomAffordance", - "imports": [ - "import { ScrollToBottomAffordance } from '@mui/x-chat/headless';", - "import { ScrollToBottomAffordance } from '@mui/x-chat/headless';" - ], + "imports": ["import { ScrollToBottomAffordance } from '@mui/x-chat/headless';"], "slots": [ { "name": "root", "description": "", "class": null }, { "name": "badge", "description": "", "class": null }, diff --git a/docs/pages/x/api/chat/typing-indicator.json b/docs/pages/x/api/chat/typing-indicator.json index 6ce9f3e7ee580..891578346b359 100644 --- a/docs/pages/x/api/chat/typing-indicator.json +++ b/docs/pages/x/api/chat/typing-indicator.json @@ -1,10 +1,7 @@ { "props": {}, "name": "TypingIndicator", - "imports": [ - "import { TypingIndicator } from '@mui/x-chat/headless';", - "import { TypingIndicator } from '@mui/x-chat/headless';" - ], + "imports": ["import { TypingIndicator } from '@mui/x-chat/headless';"], "slots": [{ "name": "root", "description": "", "class": null }], "classes": [], "muiName": "MuiTypingIndicator", diff --git a/docs/pages/x/api/chat/unread-marker.json b/docs/pages/x/api/chat/unread-marker.json index 32a556f18fb3f..095a2f4c83b6a 100644 --- a/docs/pages/x/api/chat/unread-marker.json +++ b/docs/pages/x/api/chat/unread-marker.json @@ -1,10 +1,7 @@ { "props": {}, "name": "UnreadMarker", - "imports": [ - "import { UnreadMarker } from '@mui/x-chat/headless';", - "import { UnreadMarker } from '@mui/x-chat/headless';" - ], + "imports": ["import { UnreadMarker } from '@mui/x-chat/headless';"], "slots": [ { "name": "root", "description": "", "class": null }, { "name": "label", "description": "", "class": null } diff --git a/docs/translations/api-docs/chat/chat-date-divider/chat-date-divider.json b/docs/translations/api-docs/chat/chat-date-divider/chat-date-divider.json index c31e9bf466441..ea1c6ecb18c44 100644 --- a/docs/translations/api-docs/chat/chat-date-divider/chat-date-divider.json +++ b/docs/translations/api-docs/chat/chat-date-divider/chat-date-divider.json @@ -47,6 +47,9 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the message meta element" }, + "noAvatar": { + "description": "Applied when the avatar slot is hidden (slots.avatar: null) so the grid drops the avatar track." + }, "roleAssistant": { "description": "Applied when the message role is 'assistant'" }, "roleUser": { "description": "Applied when the message role is 'user'" }, "root": { diff --git a/docs/translations/api-docs/chat/chat-message-actions/chat-message-actions.json b/docs/translations/api-docs/chat/chat-message-actions/chat-message-actions.json index c31e9bf466441..ea1c6ecb18c44 100644 --- a/docs/translations/api-docs/chat/chat-message-actions/chat-message-actions.json +++ b/docs/translations/api-docs/chat/chat-message-actions/chat-message-actions.json @@ -47,6 +47,9 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the message meta element" }, + "noAvatar": { + "description": "Applied when the avatar slot is hidden (slots.avatar: null) so the grid drops the avatar track." + }, "roleAssistant": { "description": "Applied when the message role is 'assistant'" }, "roleUser": { "description": "Applied when the message role is 'user'" }, "root": { diff --git a/docs/translations/api-docs/chat/chat-message-author-label/chat-message-author-label.json b/docs/translations/api-docs/chat/chat-message-author-label/chat-message-author-label.json index c31e9bf466441..ea1c6ecb18c44 100644 --- a/docs/translations/api-docs/chat/chat-message-author-label/chat-message-author-label.json +++ b/docs/translations/api-docs/chat/chat-message-author-label/chat-message-author-label.json @@ -47,6 +47,9 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the message meta element" }, + "noAvatar": { + "description": "Applied when the avatar slot is hidden (slots.avatar: null) so the grid drops the avatar track." + }, "roleAssistant": { "description": "Applied when the message role is 'assistant'" }, "roleUser": { "description": "Applied when the message role is 'user'" }, "root": { diff --git a/docs/translations/api-docs/chat/chat-message-avatar/chat-message-avatar.json b/docs/translations/api-docs/chat/chat-message-avatar/chat-message-avatar.json index c31e9bf466441..ea1c6ecb18c44 100644 --- a/docs/translations/api-docs/chat/chat-message-avatar/chat-message-avatar.json +++ b/docs/translations/api-docs/chat/chat-message-avatar/chat-message-avatar.json @@ -47,6 +47,9 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the message meta element" }, + "noAvatar": { + "description": "Applied when the avatar slot is hidden (slots.avatar: null) so the grid drops the avatar track." + }, "roleAssistant": { "description": "Applied when the message role is 'assistant'" }, "roleUser": { "description": "Applied when the message role is 'user'" }, "root": { diff --git a/docs/translations/api-docs/chat/chat-message-content/chat-message-content.json b/docs/translations/api-docs/chat/chat-message-content/chat-message-content.json index fd3b4fea74905..68578a1b0a903 100644 --- a/docs/translations/api-docs/chat/chat-message-content/chat-message-content.json +++ b/docs/translations/api-docs/chat/chat-message-content/chat-message-content.json @@ -54,6 +54,9 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the message meta element" }, + "noAvatar": { + "description": "Applied when the avatar slot is hidden (slots.avatar: null) so the grid drops the avatar track." + }, "roleAssistant": { "description": "Applied when the message role is 'assistant'" }, "roleUser": { "description": "Applied when the message role is 'user'" }, "root": { diff --git a/docs/translations/api-docs/chat/chat-message-error/chat-message-error.json b/docs/translations/api-docs/chat/chat-message-error/chat-message-error.json new file mode 100644 index 0000000000000..32127d46dc591 --- /dev/null +++ b/docs/translations/api-docs/chat/chat-message-error/chat-message-error.json @@ -0,0 +1,15 @@ +{ + "componentDescription": "", + "propDescriptions": {}, + "classDescriptions": { + "message": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the message text element" + }, + "retryButton": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the retry button" + }, + "root": { "description": "Styles applied to the root element." } + } +} diff --git a/docs/translations/api-docs/chat/chat-message-group/chat-message-group.json b/docs/translations/api-docs/chat/chat-message-group/chat-message-group.json index 86e92880b17d9..fa9a0f27505af 100644 --- a/docs/translations/api-docs/chat/chat-message-group/chat-message-group.json +++ b/docs/translations/api-docs/chat/chat-message-group/chat-message-group.json @@ -51,6 +51,9 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the message meta element" }, + "noAvatar": { + "description": "Applied when the avatar slot is hidden (slots.avatar: null) so the grid drops the avatar track." + }, "roleAssistant": { "description": "Applied when the message role is 'assistant'" }, "roleUser": { "description": "Applied when the message role is 'user'" }, "root": { diff --git a/docs/translations/api-docs/chat/chat-message-list/chat-message-list.json b/docs/translations/api-docs/chat/chat-message-list/chat-message-list.json index 2d1ce14d76f44..2c02ad2a87b0e 100644 --- a/docs/translations/api-docs/chat/chat-message-list/chat-message-list.json +++ b/docs/translations/api-docs/chat/chat-message-list/chat-message-list.json @@ -3,6 +3,9 @@ "propDescriptions": { "autoScroll": { "description": "

Controls automatic scrolling to the bottom when new messages arrive or\nstreaming content grows, as long as the user is within buffer pixels of\nthe bottom.

\n\n

Scrolling when the user sends a message is always active.

\n" + }, + "renderItem": { + "description": "Render a custom row for each message. When omitted, the default row used by ChatBox is rendered (group → message → avatar → content, with conditional inline meta, external meta in compact variant, and message actions slot). Provide a function to fully replace the row; use slot overrides to tweak individual sub-components without losing the default chrome." } }, "classDescriptions": { diff --git a/docs/translations/api-docs/chat/chat-message-meta/chat-message-meta.json b/docs/translations/api-docs/chat/chat-message-meta/chat-message-meta.json index c31e9bf466441..ea1c6ecb18c44 100644 --- a/docs/translations/api-docs/chat/chat-message-meta/chat-message-meta.json +++ b/docs/translations/api-docs/chat/chat-message-meta/chat-message-meta.json @@ -47,6 +47,9 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the message meta element" }, + "noAvatar": { + "description": "Applied when the avatar slot is hidden (slots.avatar: null) so the grid drops the avatar track." + }, "roleAssistant": { "description": "Applied when the message role is 'assistant'" }, "roleUser": { "description": "Applied when the message role is 'user'" }, "root": { diff --git a/packages/x-chat/src/ChatBox/ChatBox.types.ts b/packages/x-chat/src/ChatBox/ChatBox.types.ts index 9152e3ffccae8..1f89964929bfe 100644 --- a/packages/x-chat/src/ChatBox/ChatBox.types.ts +++ b/packages/x-chat/src/ChatBox/ChatBox.types.ts @@ -23,6 +23,7 @@ import type { ChatMessageContentProps } from '../ChatMessage/ChatMessageContent' import type { ChatMessageMetaProps } from '../ChatMessage/ChatMessageMeta'; import type { ChatMessageActionsProps } from '../ChatMessage/ChatMessageActions'; import type { ChatMessageGroupProps } from '../ChatMessage/ChatMessageGroup'; +import type { ChatMessageErrorProps } from '../ChatMessageError/ChatMessageError'; import type { ChatDateDividerProps } from '../ChatMessage/ChatDateDivider'; import type { ChatComposerProps } from '../ChatComposer/ChatComposer'; import type { ChatComposerTextAreaProps } from '../ChatComposer/ChatComposerTextArea'; @@ -130,6 +131,48 @@ export interface ChatBoxSlotProps { suggestions?: Partial; } +/** + * Slot map for the `messagesList` family — list-level chrome. + * + * `group` overrides the per-message group wrapper component. + */ +export interface ChatBoxMessagesListSlots { + group?: React.ElementType; + dateDivider?: React.ElementType; + unreadMarker?: React.ElementType; +} + +export interface ChatBoxMessagesListSlotProps { + group?: Partial; + dateDivider?: Partial; + unreadMarker?: Partial; +} + +/** + * Slot map for the `message` family — one row's parts. + */ +export interface ChatBoxMessageSlots { + root?: React.ElementType; + avatar?: React.ElementType | null; + content?: React.ElementType; + meta?: React.ElementType | null; + inlineMeta?: React.ElementType | null; + error?: React.ElementType; + actions?: React.ElementType | null; + authorName?: React.ElementType | null; +} + +export interface ChatBoxMessageSlotProps { + root?: Partial; + avatar?: Partial; + content?: Partial; + meta?: Partial; + inlineMeta?: Record; + error?: Partial; + actions?: Partial; + authorName?: Record; +} + export interface ChatBoxFeatures { /** * Whether to show the scroll-to-bottom affordance button when the user has scrolled up. diff --git a/packages/x-chat/src/ChatCodeBlock/ChatCodeBlock.tsx b/packages/x-chat/src/ChatCodeBlock/ChatCodeBlock.tsx index 9530dd2952675..1f0cfa7c58cae 100644 --- a/packages/x-chat/src/ChatCodeBlock/ChatCodeBlock.tsx +++ b/packages/x-chat/src/ChatCodeBlock/ChatCodeBlock.tsx @@ -2,7 +2,9 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; +import { SxProps, Theme } from '@mui/system'; import { styled, createUseThemeProps } from '../internals/zero-styled'; +import { useCopyToClipboard } from '../internals/useCopyToClipboard'; import { useChatCodeBlockUtilityClasses, type ChatCodeBlockClasses } from './chatCodeBlockClasses'; export interface ChatCodeBlockProps { @@ -23,6 +25,7 @@ export interface ChatCodeBlockProps { */ highlighter?: (code: string, language: string) => React.ReactNode; className?: string; + sx?: SxProps; classes?: Partial; } @@ -136,38 +139,24 @@ const ChatCodeBlockCode = styled('code', { const ChatCodeBlock = React.forwardRef( function ChatCodeBlock(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiChatCodeBlock' }); - const { children, language, highlighter, className, classes: classesProp, ...other } = props; + const { + children, + language, + highlighter, + className, + classes: classesProp, + sx, + ...other + } = props; const classes = useChatCodeBlockUtilityClasses(classesProp); - const [copyState, setCopyState] = React.useState<'idle' | 'copied'>('idle'); - const resetTimerRef = React.useRef | null>(null); - - React.useEffect(() => { - return () => { - if (resetTimerRef.current !== null) { - clearTimeout(resetTimerRef.current); - } - }; - }, []); - - const handleCopy = () => { - navigator.clipboard.writeText(children).then( - () => { - setCopyState('copied'); - if (resetTimerRef.current !== null) { - clearTimeout(resetTimerRef.current); - } - resetTimerRef.current = setTimeout(() => setCopyState('idle'), 2000); - }, - () => { - // Clipboard write failed (e.g. permissions denied) — no-op - }, - ); - }; + const { copyState, copy } = useCopyToClipboard(); + + const handleCopy = () => copy(children); return ( - + {language ?? ''} @@ -214,6 +203,11 @@ ChatCodeBlock.propTypes = { * Language identifier shown in the header (e.g. "typescript", "python"). */ language: PropTypes.string, + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), } as any; export { ChatCodeBlock }; diff --git a/packages/x-chat/src/ChatComposer/ChatComposer.test.tsx b/packages/x-chat/src/ChatComposer/ChatComposer.test.tsx index f9903b7be86c0..7b9d0834c5b0c 100644 --- a/packages/x-chat/src/ChatComposer/ChatComposer.test.tsx +++ b/packages/x-chat/src/ChatComposer/ChatComposer.test.tsx @@ -31,7 +31,7 @@ describe('ChatComposer', () => { expect(document.querySelector('.MuiChatComposer-root')).not.toBe(null); }); - it('forwards custom className via slotProps.composerRoot', () => { + it('forwards custom className via slotProps.composer', () => { render( { expect(document.querySelector('.MuiChatComposer-attachButton')).not.toBe(null); }); - it('forwards custom className via slotProps.composerAttachButton', () => { + it('forwards custom className via slotProps.attach', () => { render( diff --git a/packages/x-chat/src/ChatComposer/ChatComposerSendButton.test.tsx b/packages/x-chat/src/ChatComposer/ChatComposerSendButton.test.tsx index d296c229f34e9..115d9f43b11c3 100644 --- a/packages/x-chat/src/ChatComposer/ChatComposerSendButton.test.tsx +++ b/packages/x-chat/src/ChatComposer/ChatComposerSendButton.test.tsx @@ -30,7 +30,7 @@ describe('ChatComposerSendButton', () => { expect(document.querySelector('.MuiChatComposer-sendButton')).not.toBe(null); }); - it('forwards custom className via slotProps.composerSendButton', () => { + it('forwards custom className via slotProps.send', () => { render( { expect(document.querySelector('.MuiChatComposer-textArea')).not.toBe(null); }); - it('forwards custom className via slotProps.composerInput', () => { + it('forwards custom className via slotProps.input', () => { render( styles.root, })(({ theme }) => ({ + // Default inline-sidebar width token. Consumed by the scroller below and by the + // narrow-layout Drawer in ChatBoxContent; override it to resize the conversation list. '--ChatBox-conversationListWidth': '260px', display: 'flex', flexDirection: 'column', @@ -65,6 +78,9 @@ const ChatConversationListScrollerStyled = styled('div', { height: '100%', overflow: 'hidden', flexShrink: 0, + // Anonymous container query: ChatBox sets `containerType: 'inline-size'` without a + // `containerName`, so this matches the nearest size container (the ChatBox root) and + // hides the inline sidebar on narrow layouts. Mirrors `ChatBoxContent`'s JS observer. '@container (max-width: 599.95px)': { display: 'none', }, @@ -305,80 +321,321 @@ const ChatConversationListItemActionsRoot = styled('div', { // styled root so MUI theming is applied. // --------------------------------------------------------------------------- -const ChatConversationListItemAvatarStyled = React.forwardRef( - function ChatConversationListItemAvatarStyled(props: any, ref) { - return ( - - ); +// Wrapper around the styled item root that satisfies +// `React.JSXElementConstructor`. The styled root +// uses `shouldForwardProp` to filter out the conversation-related props at +// runtime, so we just spread them through. +const ChatConversationListItemSlot = React.forwardRef( + function ChatConversationListItemSlot(props, ref) { + return ; }, ); -const ChatConversationListItemContentStyled = React.forwardRef( - function ChatConversationListItemContentStyled(props: any, ref) { - return ( - - ); - }, -); +ChatConversationListItemSlot.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + children: PropTypes.node, + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; -const ChatConversationListTitleStyled = React.forwardRef( - function ChatConversationListTitleStyled(props: any, ref) { - return ( - - ); - }, -); +const ChatConversationListItemAvatarStyled = React.forwardRef< + HTMLDivElement, + ConversationListItemAvatarProps +>(function ChatConversationListItemAvatarStyled(props, ref) { + return ( + + ); +}); -const ChatConversationListPreviewStyled = React.forwardRef( - function ChatConversationListPreviewStyled(props: any, ref) { - return ( - - ); - }, -); +ChatConversationListItemAvatarStyled.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; -const ChatConversationListTimestampStyled = React.forwardRef( - function ChatConversationListTimestampStyled(props: any, ref) { - return ( - - ); - }, -); +const ChatConversationListItemContentStyled = React.forwardRef< + HTMLDivElement, + ConversationListItemContentProps +>(function ChatConversationListItemContentStyled(props, ref) { + return ( + + ); +}); -const ChatConversationListUnreadBadgeStyled = React.forwardRef( - function ChatConversationListUnreadBadgeStyled(props: any, ref) { - return ( - - ); - }, -); +ChatConversationListItemContentStyled.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; + +const ChatConversationListTitleStyled = React.forwardRef< + HTMLDivElement, + ConversationListTitleProps +>(function ChatConversationListTitleStyled(props, ref) { + return ( + + ); +}); + +ChatConversationListTitleStyled.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; + +const ChatConversationListPreviewStyled = React.forwardRef< + HTMLDivElement, + ConversationListPreviewProps +>(function ChatConversationListPreviewStyled(props, ref) { + return ( + + ); +}); + +ChatConversationListPreviewStyled.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; + +const ChatConversationListTimestampStyled = React.forwardRef< + HTMLDivElement, + ConversationListTimestampProps +>(function ChatConversationListTimestampStyled(props, ref) { + return ( + + ); +}); + +ChatConversationListTimestampStyled.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; + +const ChatConversationListUnreadBadgeStyled = React.forwardRef< + HTMLDivElement, + ConversationListUnreadBadgeProps +>(function ChatConversationListUnreadBadgeStyled(props, ref) { + return ( + + ); +}); // Default inline SVG for the 3-dot "more" icon (MoreHoriz style). +ChatConversationListUnreadBadgeStyled.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; + function DefaultMoreIcon() { return ( @@ -389,19 +646,52 @@ function DefaultMoreIcon() { ); } -const ChatConversationListItemActionsStyled = React.forwardRef( - function ChatConversationListItemActionsStyled(props: any, ref) { - return ( - - {props.children ?? } - - ); - }, -); +const ChatConversationListItemActionsStyled = React.forwardRef< + HTMLDivElement, + ConversationListItemActionsProps +>(function ChatConversationListItemActionsStyled(props, ref) { + return ( + + {props.children ?? } + + ); +}); + +ChatConversationListItemActionsStyled.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + conversation: PropTypes.shape({ + avatarUrl: PropTypes.string, + id: PropTypes.string.isRequired, + lastMessageAt: PropTypes.string, + metadata: PropTypes.object, + participants: PropTypes.arrayOf( + PropTypes.shape({ + avatarUrl: PropTypes.string, + displayName: PropTypes.string, + id: PropTypes.string.isRequired, + isOnline: PropTypes.bool, + metadata: PropTypes.object, + role: PropTypes.oneOf(['assistant', 'system', 'user']), + }), + ), + readState: PropTypes.oneOf(['read', 'unread']), + subtitle: PropTypes.string, + title: PropTypes.string, + unreadCount: PropTypes.number, + }).isRequired, + focused: PropTypes.bool, + selected: PropTypes.bool, + slotProps: PropTypes.object, + slots: PropTypes.object, + unread: PropTypes.bool, +} as any; const ChatConversationList = React.forwardRef( function ChatConversationList(inProps, ref) { @@ -418,78 +708,91 @@ const ChatConversationList = React.forwardRef = { + root: slots?.root ?? ChatConversationListStyled, + scroller: slots?.scroller ?? ChatConversationListScrollerStyled, + viewport: slots?.viewport ?? ChatConversationListViewportStyled, + scrollbar: slots?.scrollbar ?? NoopScrollbar, + scrollbarThumb: slots?.scrollbarThumb ?? NoopScrollbar, + item: slots?.item ?? ChatConversationListItemSlot, + itemAvatar: slots?.itemAvatar ?? ChatConversationListItemAvatarStyled, + itemContent: slots?.itemContent ?? ChatConversationListItemContentStyled, + title: slots?.title ?? ChatConversationListTitleStyled, + preview: slots?.preview ?? ChatConversationListPreviewStyled, + timestamp: slots?.timestamp ?? ChatConversationListTimestampStyled, + unreadBadge: slots?.unreadBadge ?? ChatConversationListUnreadBadgeStyled, + itemActions: slots?.itemActions ?? ChatConversationListItemActionsStyled, + ...slots, + }; + + // The headless `root` slot is typed as `SlotComponentProps<'div', ...>`, + // which intentionally does NOT include `sx`. We funnel `sx` to the styled + // root via a localized assertion — strictly typing the rest of the slot + // wiring still catches signature drift. + const rootSlotProps = { + className: clsx(classes.root, isCompact && classes.compact, className), + sx, + ...slotProps?.root, + } as unknown as ConversationListRootSlotProps['root']; + + const resolvedSlotProps: ConversationListRootSlotProps = { + ...slotProps, + root: rootSlotProps, + scroller: { + className: classes.scroller, + ...slotProps?.scroller, + }, + item: (ownerState: ConversationListItemOwnerState) => { + const externalItemProps = + typeof slotProps?.item === 'function' ? slotProps.item(ownerState) : slotProps?.item; + + return { + className: clsx( + classes.item, + ownerState.selected && classes.itemSelected, + ownerState.unread && classes.itemUnread, + ownerState.focused && classes.itemFocused, + ), + ...externalItemProps, + }; + }, + itemAvatar: { + className: classes.itemAvatar, + ...slotProps?.itemAvatar, + }, + itemContent: { + className: classes.itemContent, + ...slotProps?.itemContent, + }, + title: { + className: classes.itemTitle, + ...slotProps?.title, + }, + preview: { + className: classes.itemPreview, + ...slotProps?.preview, + }, + timestamp: { + className: classes.itemTimestamp, + ...slotProps?.timestamp, + }, + unreadBadge: { + className: classes.itemUnreadBadge, + ...slotProps?.unreadBadge, + }, + itemActions: { + className: classes.itemActions, + ...slotProps?.itemActions, + }, + }; + return ( ({ - className: clsx( - classes.item, - ownerState?.selected && classes.itemSelected, - ownerState?.unread && classes.itemUnread, - ownerState?.focused && classes.itemFocused, - ), - ...(typeof slotProps?.item === 'function' - ? (slotProps.item as (s: any) => any)(ownerState) - : slotProps?.item), - })) as any, - itemAvatar: { - className: classes.itemAvatar, - ...slotProps?.itemAvatar, - } as any, - itemContent: { - className: classes.itemContent, - ...slotProps?.itemContent, - } as any, - title: { - className: classes.itemTitle, - ...slotProps?.title, - } as any, - preview: { - className: classes.itemPreview, - ...slotProps?.preview, - } as any, - timestamp: { - className: classes.itemTimestamp, - ...slotProps?.timestamp, - } as any, - unreadBadge: { - className: classes.itemUnreadBadge, - ...slotProps?.unreadBadge, - } as any, - itemActions: { - className: classes.itemActions, - ...slotProps?.itemActions, - } as any, - }} + slots={resolvedSlots} + slotProps={resolvedSlotProps} /> ); }, diff --git a/packages/x-chat/src/ChatMessage/ChatDateDivider.tsx b/packages/x-chat/src/ChatMessage/ChatDateDivider.tsx index 096e3caf0dba1..b9db13f7c8aa7 100644 --- a/packages/x-chat/src/ChatMessage/ChatDateDivider.tsx +++ b/packages/x-chat/src/ChatMessage/ChatDateDivider.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; +import { SxProps, Theme } from '@mui/system'; import { MessageListDateDivider, type MessageListDateDividerProps } from '@mui/x-chat-headless'; import { styled, createUseThemeProps } from '../internals/zero-styled'; import { useChatMessageUtilityClasses, type ChatMessageClasses } from './chatMessageClasses'; @@ -10,6 +11,7 @@ const useThemeProps = createUseThemeProps('MuiChatDateDivider'); export interface ChatDateDividerProps extends MessageListDateDividerProps { className?: string; + sx?: SxProps; classes?: Partial; } @@ -49,7 +51,7 @@ const ChatDateDividerLabelStyled = styled('div', { const ChatDateDivider = React.forwardRef( function ChatDateDivider(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiChatDateDivider' }); - const { slots, slotProps, className, classes: classesProp, ...other } = props; + const { slots, slotProps, className, classes: classesProp, sx, ...other } = props; const classes = useChatMessageUtilityClasses(classesProp); return ( @@ -66,6 +68,7 @@ const ChatDateDivider = React.forwardRef( ...slotProps, divider: { className: clsx(classes.dateDivider, className), + sx, ...(slotProps?.divider as object), } as any, }} @@ -87,6 +90,11 @@ ChatDateDivider.propTypes = { messageId: PropTypes.string.isRequired, slotProps: PropTypes.object, slots: PropTypes.object, + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), } as any; export { ChatDateDivider }; diff --git a/packages/x-chat/src/ChatMessage/ChatMessage.test.tsx b/packages/x-chat/src/ChatMessage/ChatMessage.test.tsx index 493238530e3ff..088d8d0b4b3e2 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessage.test.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessage.test.tsx @@ -112,7 +112,7 @@ describe('ChatMessage', () => { expect(document.querySelector('.MuiChatMessage-root')).not.toBe(null); }); - it('forwards custom className to the message root via slotProps.messageRoot', () => { + it('forwards custom className to the message root via slotProps.message', () => { render( { expect(document.querySelector('.my-custom-class')).not.toBe(null); }); + + it("treats role:'user' messages from non-current users as outer messages (Alice case)", () => { + render( + + {null} + , + ); + + const aliceRoot = document.querySelector( + '.MuiChatMessage-root[aria-label="Message from Alice Chen"]', + ); + const meRoot = document.querySelector('.MuiChatMessage-root[aria-label="Message from Me"]'); + + expect(aliceRoot).not.toBe(null); + expect(meRoot).not.toBe(null); + + // Alice's role is 'user' — the role hook is preserved for downstream styling. + expect(aliceRoot!.classList.contains('MuiChatMessage-roleUser')).toBe(true); + // …but Alice is NOT the current user, so the bubble must NOT be marked as own. + expect(aliceRoot!.getAttribute('data-is-own-message')).toBe(null); + // The current user's own user-role message IS marked as own. + expect(meRoot!.getAttribute('data-is-own-message')).toBe('true'); + }); + + it('honors slots.error in the children composition path', () => { + function CustomError() { + return
custom error
; + } + + render( + + +
child content
+
+
, + ); + + // Custom children render, and the error surface resolves through slots.error + // rather than being hardcoded to the default ChatMessageError. + expect(document.querySelector('[data-testid="custom-child"]')).not.toBe(null); + expect(document.querySelector('[data-testid="custom-error"]')).not.toBe(null); + }); }); diff --git a/packages/x-chat/src/ChatMessage/ChatMessage.tsx b/packages/x-chat/src/ChatMessage/ChatMessage.tsx index 59da1649089f9..029527e06bfdb 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessage.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessage.tsx @@ -3,17 +3,76 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { SxProps, Theme } from '@mui/system'; -import { useMessage } from '@mui/x-chat-headless'; -import { MessageRoot, type MessageRootProps } from '@mui/x-chat-headless'; +import { + MessageRoot, + type MessageRootProps, + useChatVariant, + useMessage, +} from '@mui/x-chat-headless'; import { styled, createUseThemeProps } from '../internals/zero-styled'; import { useChatMessageUtilityClasses, type ChatMessageClasses } from './chatMessageClasses'; +import { ChatMessageError, type ChatMessageErrorProps } from '../ChatMessageError/ChatMessageError'; +import { ChatMessageAvatar, type ChatMessageAvatarProps } from './ChatMessageAvatar'; +import { ChatMessageContent, type ChatMessageContentProps } from './ChatMessageContent'; +import { ChatMessageMeta, type ChatMessageMetaProps } from './ChatMessageMeta'; +import { ChatMessageInlineMeta } from './ChatMessageInlineMeta'; +import { ChatMessageActions, type ChatMessageActionsProps } from './ChatMessageActions'; const useThemeProps = createUseThemeProps('MuiChatMessage'); -export interface ChatMessageProps extends MessageRootProps { +export interface ChatMessageSlots { + /** The styled root element. */ + root: React.ElementType; + /** + * The avatar component. Pass `null` to hide it and collapse the avatar grid track. + * Function form receives the message context and may return `null` for per-message hiding, + * but the grid track is only dropped when the slot itself is `null`. + */ + avatar: React.ElementType | null; + /** The bubble component that renders message content. */ + content: React.ElementType; + /** + * The external meta component (compact variant). Pass `null` to hide it. + */ + meta: React.ElementType | null; + /** + * The inline meta component (default variant; rendered inside the bubble). Pass `null` to hide it. + */ + inlineMeta: React.ElementType | null; + /** The error component rendered under the bubble when status === 'error'. */ + error: React.ElementType; + /** + * The actions component, rendered under the bubble. + * Receives `{ messageId }` as props. + * Pass `null` (or omit) to hide actions entirely. + */ + actions: React.ElementType | null; + /** + * The author-name label. Rendered by the surrounding `ChatMessageGroup` + * (default variant: above the bubble; compact variant: inside the message + * grid). Forwarded through `slots.message.authorName` from `ChatBox`. Pass + * `null` to hide. + */ + authorName: React.ElementType | null; +} + +export interface ChatMessageSlotProps { + root?: any; + avatar?: Partial; + content?: Partial; + meta?: Partial; + inlineMeta?: Record; + error?: Partial; + actions?: Partial; + authorName?: Record; +} + +export interface ChatMessageProps extends Omit { className?: string; sx?: SxProps; classes?: Partial; + slots?: Partial; + slotProps?: ChatMessageSlotProps; } const ChatMessageStyled = styled('div', { @@ -30,10 +89,11 @@ const ChatMessageStyled = styled('div', { isGrouped?: boolean; variant?: string; density?: string; + isOwnMessage?: boolean; }; }>(({ theme, ownerState }) => { const isCompact = ownerState?.variant === 'compact'; - const isUser = ownerState?.role === 'user'; + const isOwnMessage = ownerState?.isOwnMessage ?? false; const densityPaddingBlock: Record = { compact: theme.spacing(0.25), standard: theme.spacing(1), @@ -56,18 +116,23 @@ const ChatMessageStyled = styled('div', { fontFamily: theme.typography.fontFamily, ...(isGrouped ? { - // Grouped: no avatar/authorName rows — content + meta on the same row. - // Meta (✓ 10:55) sits top-right, aligned to the start of the content. gridTemplateColumns: 'var(--MuiChatMessage-avatarSize) 1fr auto', - gridTemplateAreas: '". content meta"', + gridTemplateRows: 'auto auto', + gridTemplateAreas: '". content meta" ". error ."', } : { - // First in group: avatar spans authorName + content rows. - // Meta (✓ 10:55) sits top-right on the same row as the author name. gridTemplateColumns: 'var(--MuiChatMessage-avatarSize) 1fr auto', - gridTemplateRows: 'auto auto', - gridTemplateAreas: '"avatar authorName meta" "avatar content ."', + gridTemplateRows: 'auto auto auto', + gridTemplateAreas: '"avatar authorName meta" "avatar content ." ". error ."', }), + // Avatar-less layout: collapse the reserved avatar grid track so the bubble + // and meta lane reclaim the row. Applies to both grouped and first-in-group. + '&.MuiChatMessage-noAvatar': { + gridTemplateColumns: '1fr auto', + gridTemplateAreas: isGrouped + ? '"content meta" "error ."' + : '"authorName meta" "content ." "error ."', + }, }; } @@ -79,8 +144,10 @@ const ChatMessageStyled = styled('div', { return { display: 'grid', gridTemplateColumns: isGrouped ? 'var(--MuiChatMessage-avatarSize) 1fr' : 'auto 1fr', - gridTemplateRows: 'auto auto', - gridTemplateAreas: isGrouped ? '". content" ". actions"' : '"avatar content" ". actions"', + gridTemplateRows: 'auto auto auto', + gridTemplateAreas: isGrouped + ? '". content" ". error" ". actions"' + : '"avatar content" ". error" ". actions"', columnGap: theme.spacing(0.5), width: '100%', boxSizing: 'border-box', @@ -95,21 +162,42 @@ const ChatMessageStyled = styled('div', { paddingBlockEnd: theme.spacing(0.25), }, fontFamily: theme.typography.fontFamily, - ...(isUser && { + ...(isOwnMessage && { gridTemplateColumns: isGrouped ? '1fr var(--MuiChatMessage-avatarSize)' : '1fr auto', - gridTemplateAreas: isGrouped ? '"content ." "actions ."' : '"content avatar" "actions ."', + gridTemplateAreas: isGrouped + ? '"content ." "error ." "actions ."' + : '"content avatar" "error ." "actions ."', paddingInlineStart: `calc(${theme.spacing(2)} + var(--MuiChatMessage-avatarSize))`, paddingInlineEnd: theme.spacing(2), }), + // Avatar-less layout: collapse the reserved avatar column and drop the phantom + // padding so the bubble fills the full content lane on both sides. + '&.MuiChatMessage-noAvatar': { + gridTemplateColumns: '1fr', + gridTemplateAreas: '"content" "error" "actions"', + paddingInlineStart: theme.spacing(2), + paddingInlineEnd: theme.spacing(2), + }, }; }); const ChatMessage = React.forwardRef( function ChatMessage(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiChatMessage' }); - const { slots, slotProps, className, classes: classesProp, sx, messageId, ...other } = props; + const { + slots, + slotProps, + className, + classes: classesProp, + sx, + messageId, + children, + ...other + } = props; const classes = useChatMessageUtilityClasses(classesProp); const message = useMessage(messageId); + const variant = useChatVariant(); + const isCompact = variant === 'compact'; const stateClasses = clsx( message?.role === 'user' && classes.roleUser, @@ -118,6 +206,61 @@ const ChatMessage = React.forwardRef( message?.status === 'error' && classes.error, ); + // Slot resolution: `null` means "hide and adjust layout"; `undefined` means "use default". + const AvatarSlot = slots?.avatar; + const hasAvatar = AvatarSlot !== null; + + // The error surface is resolved through the `error` slot in both branches so custom + // message composition via `children` can still customize (or keep consistent) the + // error rendering, matching the slot-driven path below. + const ErrorComp = (slots?.error ?? ChatMessageError) as typeof ChatMessageError; + + // Build the inner tree from slots when no `children` were passed (slot-driven). + // When `children` are provided, render them as-is for backward compatibility. + let innerTree: React.ReactNode; + if (children !== undefined) { + innerTree = ( + + {children} + + + ); + } else { + const ContentComp = (slots?.content ?? ChatMessageContent) as typeof ChatMessageContent; + const MetaSlot = slots?.meta; + const InlineMetaSlot = slots?.inlineMeta; + const ActionsSlot = slots?.actions; + const AvatarComp = (AvatarSlot ?? ChatMessageAvatar) as typeof ChatMessageAvatar; + + const isStreaming = message?.status === 'streaming'; + const hasMeta = + Boolean(message?.createdAt) || Boolean(message?.editedAt) || Boolean(message?.status); + const InlineMetaComp = (InlineMetaSlot ?? + ChatMessageInlineMeta) as typeof ChatMessageInlineMeta; + const inlineMeta = + !isCompact && !isStreaming && hasMeta && InlineMetaSlot !== null + ? React.createElement(InlineMetaComp, slotProps?.inlineMeta ?? {}) + : undefined; + + const MetaComp = (MetaSlot ?? ChatMessageMeta) as typeof ChatMessageMeta; + const externalMeta = + isCompact && MetaSlot !== null ? : null; + + innerTree = ( + + {hasAvatar && } + + {externalMeta} + + {ActionsSlot && ( + + + + )} + + ); + } + return ( ( {...other} slots={{ root: slots?.root ?? ChatMessageStyled, - ...slots, }} slotProps={{ - ...slotProps, root: { - className: clsx(classes.root, stateClasses, className), + className: clsx(classes.root, stateClasses, !hasAvatar && classes.noAvatar, className), sx, ...slotProps?.root, } as any, }} - /> + > + {innerTree} + ); }, ); diff --git a/packages/x-chat/src/ChatMessage/ChatMessageActions.tsx b/packages/x-chat/src/ChatMessage/ChatMessageActions.tsx index 4c26a58504108..82980074ff430 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessageActions.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessageActions.tsx @@ -19,7 +19,7 @@ const ChatMessageActionsStyled = styled('div', { name: 'MuiChatMessage', slot: 'Actions', overridesResolver: (_, styles) => styles.actions, -})<{ ownerState?: { role?: string } }>(({ theme, ownerState }) => ({ +})<{ ownerState?: { role?: string; isOwnMessage?: boolean } }>(({ theme, ownerState }) => ({ gridArea: 'actions', display: 'inline-flex', alignItems: 'center', @@ -34,7 +34,7 @@ const ChatMessageActionsStyled = styled('div', { '.MuiChatMessage-root:hover &, .MuiChatMessage-root:focus-within &': { opacity: 1, }, - ...(ownerState?.role === 'user' && { + ...(ownerState?.isOwnMessage && { justifySelf: 'end', }), backgroundColor: (theme.vars || theme).palette.background.paper, diff --git a/packages/x-chat/src/ChatMessage/ChatMessageContent.test.tsx b/packages/x-chat/src/ChatMessage/ChatMessageContent.test.tsx index a6db4b572d311..51609a295b7be 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessageContent.test.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessageContent.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { createRenderer, screen } from '@mui/internal-test-utils'; +import { act, createRenderer, screen } from '@mui/internal-test-utils'; import { describe, expect, it } from 'vitest'; import type { ChatAdapter, ChatMessage } from '@mui/x-chat-headless'; -import { ChatRoot, MessageRoot } from '@mui/x-chat-headless'; +import { ChatRoot, MessageRoot, useChatStore } from '@mui/x-chat-headless'; import { ChatMessageContent } from './ChatMessageContent'; const { render } = createRenderer(); @@ -160,7 +160,7 @@ describe('ChatMessageContent', () => { expect(screen.getByText('Deny')).not.toBe(null); }); - it('tool icon shows first letter of toolName', () => { + it('renders a default tool icon SVG in the header', () => { renderWithMessage({ id: 'm1', role: 'assistant', @@ -176,8 +176,167 @@ describe('ChatMessageContent', () => { }, ], }); - // The icon component renders the first letter uppercased - expect(screen.getByText('S')).not.toBe(null); + // The default icon is now an inline SVG inside the styled icon span, + // sitting in the summary header alongside the tool title. + const summary = document.querySelector('summary'); + expect(summary).not.toBe(null); + expect(summary!.querySelector('svg')).not.toBe(null); + }); + + it('renders Input and Output as independently collapsible sections', () => { + renderWithMessage({ + id: 'm1', + role: 'assistant', + parts: [ + { + type: 'tool', + toolInvocation: { + toolCallId: 'tc1', + toolName: 'searchTool', + state: 'output-available', + input: { query: 'test' }, + output: { result: 'found' }, + title: 'Search Tool', + }, + }, + ], + }); + // Find all
— root + 2 sections (input + output) = 3. + const allDetails = document.querySelectorAll('details'); + expect(allDetails.length).toBe(3); + expect(screen.getByText('Tool called:')).not.toBe(null); + expect(screen.getByText('Tool result:')).not.toBe(null); + }); + + it('renders a status icon with state aria-label when output is available', () => { + renderWithMessage({ + id: 'm1', + role: 'assistant', + parts: [ + { + type: 'tool', + toolInvocation: { + toolCallId: 'tc1', + toolName: 'searchTool', + state: 'output-available', + input: {}, + output: {}, + }, + }, + ], + }); + // State component is a [role="status"] element with aria-label = locale state label. + const status = document.querySelector('[role="status"]'); + expect(status).not.toBe(null); + expect(status!.getAttribute('aria-label')).toBe('Completed'); + expect(status!.querySelector('svg')).not.toBe(null); + }); + + it('auto-opens the input section when the state transitions to approval-requested', () => { + let store!: ReturnType; + function CaptureStore() { + store = useChatStore(); + return null; + } + + render( + + + + + + , + ); + + // The input section starts collapsed while the tool is `input-available`. + const inputDetailsBefore = screen.getByText('Tool called:').closest('details'); + expect(inputDetailsBefore).not.toBe(null); + expect(inputDetailsBefore!.hasAttribute('open')).toBe(false); + + // The same section component is reused when the state advances to + // `approval-requested`; it must now auto-open so the input being approved is visible. + act(() => { + store.updateMessage('m1', { + parts: [ + { + type: 'tool', + toolInvocation: { + toolCallId: 'tc1', + toolName: 'dangerousTool', + state: 'approval-requested', + input: { action: 'delete' }, + }, + }, + ], + }); + }); + + const inputDetailsAfter = screen.getByText('Tool called:').closest('details'); + expect(inputDetailsAfter!.hasAttribute('open')).toBe(true); + }); + + it('keeps the other Material tool slots when only one part slot is overridden', () => { + function CustomSummary({ children }: { children?: React.ReactNode }) { + return {children}; + } + + render( + + + + + , + ); + + // The overridden slot is used… + expect(document.querySelector('[data-testid="custom-summary"]')).not.toBe(null); + // …and the other Material defaults survive: the collapsible root + the input/output + // section `
` elements (3 total) would collapse to plain divs if the partial + // override had replaced the whole slot map. + expect(document.querySelectorAll('details').length).toBe(3); }); }); diff --git a/packages/x-chat/src/ChatMessage/ChatMessageContent.tsx b/packages/x-chat/src/ChatMessage/ChatMessageContent.tsx index 23b3c392c8fbd..92f1c39cffd56 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessageContent.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessageContent.tsx @@ -5,9 +5,148 @@ import clsx from 'clsx'; import { alpha } from '@mui/material/styles'; import { MessageContent, type MessageContentProps } from '@mui/x-chat-headless'; import { styled, createUseThemeProps } from '../internals/zero-styled'; +import { useCopyToClipboard } from '../internals/useCopyToClipboard'; import { useChatMessageUtilityClasses, type ChatMessageClasses } from './chatMessageClasses'; import { renderMarkdown } from './renderMarkdown'; +// --------------------------------------------------------------------------- +// Inline SVG icons — kept module-local to avoid an @mui/icons-material dep. +// --------------------------------------------------------------------------- + +function ToolDefaultIcon(props: React.SVGAttributes) { + return ( + + ); +} + +function ReasoningDefaultIcon(props: React.SVGAttributes) { + // Simplified brain — two lobes with a central seam. + return ( + + ); +} + +function StatusCheckIcon(props: React.SVGAttributes) { + return ( + + ); +} + +function StatusCrossIcon(props: React.SVGAttributes) { + return ( + + ); +} + +function StatusWarningIcon(props: React.SVGAttributes) { + return ( + + ); +} + +function StatusSpinnerIcon(props: React.SVGAttributes) { + return ( + + ); +} + +function CopySvgIcon() { + return ( + + ); +} + +function CheckSvgIcon() { + return ( + + ); +} + const useThemeProps = createUseThemeProps('MuiChatMessageContent'); export interface ChatMessageContentProps extends MessageContentProps { @@ -32,8 +171,12 @@ const ChatMessageBubbleStyled = styled('div', { name: 'MuiChatMessage', slot: 'Bubble', overridesResolver: (_, styles) => styles.bubble, -})<{ ownerState?: { role?: string; variant?: string } }>(({ theme, ownerState }) => { - const isUser = ownerState?.role === 'user'; +})<{ ownerState?: { role?: string; variant?: string; isOwnMessage?: boolean } }>(({ + theme, + ownerState, +}) => { + const isUserRole = ownerState?.role === 'user'; + const isOwn = ownerState?.isOwnMessage ?? false; const isCompact = ownerState?.variant === 'compact'; // Base text styles shared between default and compact modes. @@ -41,7 +184,9 @@ const ChatMessageBubbleStyled = styled('div', { fontSize: theme.typography.body2.fontSize, lineHeight: theme.typography.body2.lineHeight, wordBreak: 'break-word' as const, - whiteSpace: (isUser ? 'pre-wrap' : 'normal') as React.CSSProperties['whiteSpace'], + // Plain text (user role) preserves user-typed newlines; markdown-rendered + // assistant content collapses whitespace as usual. + whiteSpace: (isUserRole ? 'pre-wrap' : 'normal') as React.CSSProperties['whiteSpace'], maxWidth: '100%', boxSizing: 'border-box' as const, '& p': { @@ -100,22 +245,23 @@ const ChatMessageBubbleStyled = styled('div', { position: 'relative', // Anchor for inline meta (timestamp + status) padding: theme.spacing(1, 1.5), borderRadius: theme.shape.borderRadius, - // Shrink to fit content width; align to the side that matches the message role. - alignSelf: isUser ? 'flex-end' : 'flex-start', + // Shrink to fit content width; own messages align to the trailing edge so + // assistant and other-user bubbles share the leading edge. + alignSelf: isOwn ? 'flex-end' : 'flex-start', '& pre': { margin: 0, borderRadius: theme.shape.borderRadius, overflow: 'auto', padding: theme.spacing(1), fontSize: theme.typography.caption.fontSize, - background: isUser + background: isOwn ? alpha(theme.palette.common.white, 0.15) : (theme.vars || theme).palette.action.hover, }, '& code': { fontFamily: 'monospace', fontSize: '0.875em', - background: isUser + background: isOwn ? alpha(theme.palette.common.white, 0.15) : (theme.vars || theme).palette.action.hover, padding: '0.1em 0.3em', @@ -137,14 +283,14 @@ const ChatMessageBubbleStyled = styled('div', { background: 'none', padding: 0, }, - backgroundColor: isUser + backgroundColor: isOwn ? (theme.vars || theme).palette.primary.main : (theme.vars || theme).palette.grey[100], - ...(!isUser && + ...(!isOwn && theme.applyStyles('dark', { backgroundColor: (theme.vars || theme).palette.grey[800], })), - color: isUser + color: isOwn ? (theme.vars || theme).palette.primary.contrastText : (theme.vars || theme).palette.text.primary, }; @@ -159,13 +305,20 @@ const ChatToolPartDetailsStyled = styled('details', { slot: 'ToolRoot', shouldForwardProp: (prop) => prop !== 'ownerState', })<{ ownerState?: { state?: string } }>(({ theme }) => ({ - border: `1px solid ${(theme.vars || theme).palette.divider}`, - borderRadius: theme.shape.borderRadius, - overflow: 'hidden', - margin: theme.spacing(0.75, 0), + margin: theme.spacing(0.5, 0), fontSize: theme.typography.caption.fontSize, fontFamily: theme.typography.fontFamily, whiteSpace: 'normal', + // Indent everything after the header (sections, errors, actions) with a + // shared left-border accent — gives the expanded body a clean tree look. + '& > summary ~ *': { + marginLeft: 8, + paddingLeft: theme.spacing(1.25), + borderLeft: `1px solid ${(theme.vars || theme).palette.divider}`, + }, + '& > summary ~ * + *': { + marginTop: theme.spacing(0.25), + }, })); // Collapsible root: manages open/close state, auto-opens when approval is requested @@ -203,19 +356,35 @@ const ChatToolPartHeader = styled('summary', { name: 'MuiChatMessage', slot: 'ToolHeader', })(({ theme }) => ({ - display: 'flex', + display: 'inline-flex', alignItems: 'center', - gap: theme.spacing(1), - padding: theme.spacing(0.75, 1.25), - backgroundColor: (theme.vars || theme).palette.action.hover, + gap: theme.spacing(0.625), + padding: theme.spacing(0.25, 0), cursor: 'pointer', userSelect: 'none', listStyleType: 'none', + borderRadius: theme.shape.borderRadius, '&::-webkit-details-marker': { display: 'none' }, '&::marker': { display: 'none' }, - // Separator between header and body when expanded - 'details[open] > &': { - borderBottom: `1px solid ${(theme.vars || theme).palette.divider}`, + // Chevron renders at the end of the header (after icon, title, state). + '&::after': { + content: '""', + display: 'inline-block', + width: 5, + height: 5, + borderRight: `1.25px solid ${(theme.vars || theme).palette.text.secondary}`, + borderBottom: `1.25px solid ${(theme.vars || theme).palette.text.secondary}`, + transform: 'rotate(-45deg)', + transition: 'transform 150ms ease', + flexShrink: 0, + marginLeft: theme.spacing(0.25), + opacity: 0.7, + }, + 'details[open] > &::after': { + transform: 'rotate(45deg)', + }, + '&:hover::after': { + opacity: 1, }, })); @@ -227,39 +396,12 @@ const ChatToolPartIconStyled = styled('span', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: 18, - height: 18, - borderRadius: typeof theme.shape.borderRadius === 'number' ? theme.shape.borderRadius / 2 : 2, - // Default (expanded): show the tool icon character at normal size - fontSize: '0.6rem', - fontFamily: 'monospace', - fontWeight: theme.typography.fontWeightBold, + width: 16, + height: 16, flexShrink: 0, - backgroundColor: (theme.vars || theme).palette.action.selected, color: (theme.vars || theme).palette.text.secondary, - userSelect: 'none', + fontSize: '1rem', lineHeight: 1, - transition: 'opacity 100ms', - // Collapsed + hovering summary: hide icon char, show '+' hint - 'details:not([open]) summary:hover &': { - fontSize: 0, - '&::before': { - content: '"+"', - fontSize: '0.8rem', - fontWeight: theme.typography.fontWeightRegular, - lineHeight: 1, - }, - }, - // Expanded + hovering summary: hide icon char, show '−' hint - 'details[open] summary:hover &': { - fontSize: 0, - '&::before': { - content: '"−"', - fontSize: '0.9rem', - fontWeight: theme.typography.fontWeightRegular, - lineHeight: 1, - }, - }, })); interface ChatToolPartIconProps extends React.HTMLAttributes { @@ -267,16 +409,14 @@ interface ChatToolPartIconProps extends React.HTMLAttributes { } /** - * Default icon: shows the first letter of the tool name. + * Default icon: a generic tool/wrench glyph. * Replace per tool via `toolSlots` / `toolSlotProps` on `partProps.tool`. */ const ChatToolPartIconComponent = React.forwardRef( function ChatToolPartIcon({ ownerState, ...rest }, ref) { - const toolName = ownerState?.toolName ?? ''; - const letter = toolName.length > 0 ? toolName[0].toUpperCase() : '⚙'; return ( - {letter} + ); }, @@ -296,15 +436,13 @@ const ChatToolPartTitle = styled('div', { name: 'MuiChatMessage', slot: 'ToolTitle', })(({ theme }) => ({ - fontFamily: 'monospace', - fontSize: theme.typography.caption.fontSize, + fontSize: theme.typography.body2.fontSize, fontWeight: theme.typography.fontWeightMedium, color: (theme.vars || theme).palette.text.primary, lineHeight: 1.4, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - flex: 1, minWidth: 0, })); @@ -328,6 +466,31 @@ function resolveToolStatePalette(state: string | undefined): ToolStatePalette { } } +function resolveToolStatusIcon(state: string | undefined): React.ReactNode { + switch (state) { + case 'output-available': + return ; + case 'output-error': + case 'output-denied': + return ; + case 'approval-requested': + return ; + case 'input-streaming': + case 'input-available': + case 'approval-responded': + return ; + default: + return null; + } +} + +const spinnerKeyframes = { + '@keyframes mui-chat-tool-spin': { + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, + }, +}; + const ChatToolPartState = styled('span', { name: 'MuiChatMessage', slot: 'ToolState', @@ -338,73 +501,299 @@ const ChatToolPartState = styled('span', { const color = isDefault ? (theme.vars || theme).palette.text.secondary : (theme.vars || theme).palette[palette].main; - // Use theme.palette (not theme.vars) for alpha so we always get a real color string. - const bg = isDefault - ? (theme.vars || theme).palette.action.selected - : alpha(theme.palette[palette].main, 0.12); return { + ...spinnerKeyframes, display: 'inline-flex', alignItems: 'center', - padding: theme.spacing(0, 0.75), - height: 18, - borderRadius: 9, - fontSize: '0.6rem', - fontWeight: theme.typography.fontWeightMedium, - textTransform: 'uppercase', - letterSpacing: '0.06em', + justifyContent: 'center', + width: 14, + height: 14, flexShrink: 0, - whiteSpace: 'nowrap', - backgroundColor: bg, color, + fontSize: '0.875rem', + lineHeight: 1, + '& .MuiChatMessage-ToolStateSpinner': { + animation: 'mui-chat-tool-spin 1s linear infinite', + }, }; }); -const ChatToolPartSection = styled('div', { +interface ChatToolPartStateRenderProps extends React.HTMLAttributes { + ownerState?: { state?: string }; + children?: React.ReactNode; +} + +const ChatToolPartStateComponent = React.forwardRef( + function ChatToolPartStateRender({ ownerState, children: _label, ...rest }, ref) { + const icon = resolveToolStatusIcon(ownerState?.state); + if (icon === null) { + return null; + } + return ( + + {icon} + + ); + }, +); + +ChatToolPartStateComponent.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + children: PropTypes.node, + ownerState: PropTypes.shape({ + state: PropTypes.string, + }), +} as any; + +const ChatToolPartSectionDetails = styled('details', { name: 'MuiChatMessage', slot: 'ToolSection', -})(({ theme }) => ({ - padding: theme.spacing(0.75, 1.25), - '& + &': { - borderTop: `1px solid ${(theme.vars || theme).palette.divider}`, - }, - '& strong': { - display: 'block', - fontSize: '0.6rem', + shouldForwardProp: (prop) => prop !== 'ownerState', +})<{ ownerState?: { section?: string } }>(({ theme }) => ({ + margin: 0, + fontSize: theme.typography.caption.fontSize, +})); + +const ChatToolPartSectionSummaryStyled = styled('summary', { + name: 'MuiChatMessage', + slot: 'ToolSectionSummary', + shouldForwardProp: (prop) => prop !== 'ownerState', +})<{ ownerState?: { section?: string } }>(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(0.5), + cursor: 'pointer', + userSelect: 'none', + listStyleType: 'none', + fontSize: theme.typography.body2.fontSize, + color: (theme.vars || theme).palette.text.primary, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(0.125, 0), + '&::-webkit-details-marker': { display: 'none' }, + '&::marker': { display: 'none' }, + '& .MuiChatMessage-ToolSectionLabel': { fontWeight: theme.typography.fontWeightMedium, - textTransform: 'uppercase', - letterSpacing: '0.06em', - color: (theme.vars || theme).palette.text.disabled, - marginBottom: theme.spacing(0.5), + color: (theme.vars || theme).palette.text.primary, + flexShrink: 0, + }, + '& .MuiChatMessage-ToolSectionPreview': { + color: (theme.vars || theme).palette.text.secondary, + fontWeight: theme.typography.fontWeightRegular, + fontFamily: 'monospace', + fontSize: theme.typography.caption.fontSize, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + // Compact chevron — uses CSS-border square so it scales with currentColor. + '&::before': { + content: '""', + display: 'inline-block', + width: 5, + height: 5, + borderRight: `1.25px solid ${(theme.vars || theme).palette.text.secondary}`, + borderBottom: `1.25px solid ${(theme.vars || theme).palette.text.secondary}`, + transform: 'rotate(-45deg)', + transition: 'transform 150ms ease', + flexShrink: 0, + opacity: 0.7, + }, + 'details[open] > &::before': { + transform: 'rotate(45deg)', + }, + '&:hover::before': { + opacity: 1, + }, + // Hide preview when the section is open + 'details[open] > & .MuiChatMessage-ToolSectionPreview': { + display: 'none', }, })); -const ChatToolPartSectionContent = styled('pre', { +interface ChatToolPartSectionRenderProps extends React.HTMLAttributes { + ownerState?: { section?: 'input' | 'output'; state?: string }; +} + +function ChatToolPartSection({ ownerState, ...rest }: ChatToolPartSectionRenderProps) { + const section = ownerState?.section; + const state = ownerState?.state; + const initialOpen = React.useMemo(() => { + if (section === 'input') { + return state === 'input-streaming' || state === 'approval-requested'; + } + if (section === 'output') { + return state === 'output-available'; + } + return false; + }, [section, state]); + const [open, setOpen] = React.useState(initialOpen); + + // The section component is reused across streamed invocation-state updates (same + // element identity), so `useState(initialOpen)` alone never reacts to post-mount + // transitions (e.g. `input-available` → `approval-requested`, or `output-available`). + // Mirror the auto-open behavior of `ChatToolPartRoot`: force open when the section + // newly becomes one that should be open, while leaving a user's manual collapse + // untouched otherwise (the effect only re-runs when `initialOpen` flips). + React.useEffect(() => { + if (initialOpen) { + setOpen(true); + } + }, [initialOpen]); + + return ( + ) => { + setOpen((event.currentTarget as HTMLDetailsElement).open); + }} + {...rest} + /> + ); +} + +interface ChatToolPartSectionSummaryProps extends React.HTMLAttributes { + ownerState?: { + section?: 'input' | 'output'; + summaryLabel?: string; + previewValue?: string; + }; + children?: React.ReactNode; +} + +const ChatToolPartSectionSummary = React.forwardRef( + function ChatToolPartSectionSummary({ ownerState, children, ...rest }, ref) { + const label = ownerState?.summaryLabel ?? (typeof children === 'string' ? children : ''); + const preview = ownerState?.previewValue ?? ''; + return ( + } + ownerState={ownerState} + {...(rest as any)} + > + {label}: + {preview ? {preview} : null} + + ); + }, +); + +ChatToolPartSectionSummary.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + children: PropTypes.node, + ownerState: PropTypes.shape({ + previewValue: PropTypes.string, + section: PropTypes.oneOf(['input', 'output']), + summaryLabel: PropTypes.string, + }), +} as any; + +const ChatToolPartSectionContentWrapper = styled('div', { name: 'MuiChatMessage', slot: 'ToolSectionContent', })(({ theme }) => ({ + position: 'relative', + marginTop: theme.spacing(0.375), + border: `1px solid ${(theme.vars || theme).palette.divider}`, + borderRadius: theme.shape.borderRadius, + backgroundColor: (theme.vars || theme).palette.background.paper, + overflow: 'hidden', +})); + +const ChatToolPartSectionContentPre = styled('pre')(({ theme }) => ({ margin: 0, - padding: theme.spacing(0.75, 1), - borderRadius: typeof theme.shape.borderRadius === 'number' ? theme.shape.borderRadius / 2 : 2, - backgroundColor: (theme.vars || theme).palette.action.hover, + padding: theme.spacing(1, 1.25), + paddingRight: theme.spacing(4.5), // reserve room for the copy button fontSize: theme.typography.caption.fontSize, fontFamily: 'monospace', lineHeight: 1.5, overflow: 'auto', - maxHeight: 240, + maxHeight: 320, color: (theme.vars || theme).palette.text.primary, whiteSpace: 'pre', wordBreak: 'normal', })); +const ChatToolPartSectionCopyButton = styled('button')(({ theme }) => ({ + position: 'absolute', + top: theme.spacing(0.375), + right: theme.spacing(0.375), + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + padding: 0, + background: 'transparent', + border: 'none', + borderRadius: typeof theme.shape.borderRadius === 'number' ? theme.shape.borderRadius / 2 : 2, + color: (theme.vars || theme).palette.text.secondary, + cursor: 'pointer', + lineHeight: 0, + fontSize: '0.875rem', + opacity: 0.6, + transition: theme.transitions.create(['background-color', 'color', 'opacity']), + '&:hover': { + backgroundColor: (theme.vars || theme).palette.action.hover, + color: (theme.vars || theme).palette.text.primary, + opacity: 1, + }, + '&:focus-visible': { + outline: `2px solid ${(theme.vars || theme).palette.primary.main}`, + outlineOffset: 2, + opacity: 1, + }, +})); + +interface ChatToolPartSectionContentRenderProps extends React.HTMLAttributes { + ownerState?: { section?: 'input' | 'output' }; + children?: React.ReactNode; +} + +function ChatToolPartSectionContent({ + ownerState: _ownerState, + children, + ...rest +}: ChatToolPartSectionContentRenderProps) { + const text = typeof children === 'string' ? children : React.Children.toArray(children).join(''); + const { copyState, copy } = useCopyToClipboard(); + return ( + + {children} + {text.length > 0 ? ( + copy(text)} + title={copyState === 'copied' ? 'Copied!' : 'Copy'} + aria-label={copyState === 'copied' ? 'Copied' : 'Copy to clipboard'} + > + {copyState === 'copied' ? : } + + ) : null} + + ); +} + const ChatToolPartError = styled('div', { name: 'MuiChatMessage', slot: 'ToolError', })(({ theme }) => ({ - padding: theme.spacing(0.75, 1.25), color: (theme.vars || theme).palette.error.main, fontSize: theme.typography.caption.fontSize, - borderTop: `1px solid ${(theme.vars || theme).palette.divider}`, })); const ChatToolPartActions = styled('div', { @@ -413,9 +802,6 @@ const ChatToolPartActions = styled('div', { })(({ theme }) => ({ display: 'flex', gap: theme.spacing(1), - padding: theme.spacing(0.75, 1.25), - borderTop: `1px solid ${(theme.vars || theme).palette.divider}`, - backgroundColor: (theme.vars || theme).palette.action.hover, })); const toolActionButtonBase = ({ theme }: { theme: any }) => ({ @@ -470,9 +856,10 @@ const toolPartSlots = { root: ChatToolPartRoot, header: ChatToolPartHeader, title: ChatToolPartTitle, - state: ChatToolPartState, + state: ChatToolPartStateComponent, icon: ChatToolPartIconComponent, section: ChatToolPartSection, + sectionSummary: ChatToolPartSectionSummary, sectionContent: ChatToolPartSectionContent, error: ChatToolPartError, actions: ChatToolPartActions, @@ -488,51 +875,92 @@ const ChatReasoningPartRoot = styled('details', { name: 'MuiChatMessage', slot: 'ReasoningRoot', })(({ theme }) => ({ - borderLeft: `2px solid ${(theme.vars || theme).palette.divider}`, - paddingLeft: theme.spacing(1), margin: theme.spacing(0.5, 0), - '&[open] summary::marker, &[open] summary::-webkit-details-marker': { - // handled via pseudo-element below - }, + fontSize: theme.typography.caption.fontSize, })); -const ChatReasoningPartSummary = styled('summary', { +const ChatReasoningPartSummaryStyled = styled('summary', { name: 'MuiChatMessage', slot: 'ReasoningSummary', })(({ theme }) => ({ cursor: 'pointer', - fontSize: theme.typography.caption.fontSize, - color: (theme.vars || theme).palette.text.disabled, + fontSize: theme.typography.body2.fontSize, + color: (theme.vars || theme).palette.text.secondary, userSelect: 'none', listStyleType: 'none', - '&::-webkit-details-marker': { - display: 'none', - }, - '&::marker': { - display: 'none', - }, - display: 'flex', + display: 'inline-flex', alignItems: 'center', - gap: theme.spacing(0.5), - '&::before': { - content: '"▶"', - fontSize: '0.5rem', + gap: theme.spacing(0.625), + padding: theme.spacing(0.25, 0), + '&::-webkit-details-marker': { display: 'none' }, + '&::marker': { display: 'none' }, + '& .MuiChatMessage-ReasoningIcon': { + display: 'inline-flex', + alignItems: 'center', + fontSize: '1rem', + color: (theme.vars || theme).palette.text.secondary, + flexShrink: 0, + }, + '&::after': { + content: '""', display: 'inline-block', - transition: 'transform 0.15s ease', + width: 5, + height: 5, + borderRight: `1.25px solid ${(theme.vars || theme).palette.text.secondary}`, + borderBottom: `1.25px solid ${(theme.vars || theme).palette.text.secondary}`, + transform: 'rotate(-45deg)', + transition: 'transform 150ms ease', + flexShrink: 0, + marginLeft: theme.spacing(0.25), + opacity: 0.7, }, - 'details[open] > &::before': { - transform: 'rotate(90deg)', + 'details[open] > &::after': { + transform: 'rotate(45deg)', + }, + '&:hover::after': { + opacity: 1, }, })); +interface ChatReasoningSummaryProps extends React.HTMLAttributes { + ownerState?: { streaming?: boolean }; + children?: React.ReactNode; +} + +const ChatReasoningPartSummary = React.forwardRef( + function ChatReasoningPartSummaryRender({ ownerState, children, ...rest }, ref) { + return ( + } {...(rest as any)}> + + + + {children} + + ); + }, +); + +ChatReasoningPartSummary.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + children: PropTypes.node, + ownerState: PropTypes.shape({ + streaming: PropTypes.bool, + }), +} as any; + const ChatReasoningPartContent = styled('div', { name: 'MuiChatMessage', slot: 'ReasoningContent', })(({ theme }) => ({ marginTop: theme.spacing(0.5), - fontSize: theme.typography.caption.fontSize, - color: (theme.vars || theme).palette.text.disabled, - fontStyle: 'italic', + marginLeft: 8, + paddingLeft: theme.spacing(1.25), + borderLeft: `1px solid ${(theme.vars || theme).palette.divider}`, + fontSize: theme.typography.body2.fontSize, + color: (theme.vars || theme).palette.text.secondary, whiteSpace: 'pre-wrap', lineHeight: 1.6, })); @@ -734,29 +1162,33 @@ const ChatMessageContent = React.forwardRef diff --git a/packages/x-chat/src/ChatMessage/ChatMessageGroup.test.tsx b/packages/x-chat/src/ChatMessage/ChatMessageGroup.test.tsx index 68644ede21ea8..a22fac5173155 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessageGroup.test.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessageGroup.test.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { createRenderer } from '@mui/internal-test-utils'; +import { createRenderer, screen } from '@mui/internal-test-utils'; import { describe, expect, it } from 'vitest'; -import type { ChatAdapter } from '@mui/x-chat-headless'; +import { ChatRoot, type ChatAdapter } from '@mui/x-chat-headless'; import { ChatBox } from '../ChatBox/ChatBox'; +import { ChatMessageGroup } from './ChatMessageGroup'; const { render } = createRenderer(); @@ -71,4 +72,93 @@ describe('ChatMessageGroup', () => { // One group per author run expect(groups.length).toBeGreaterThanOrEqual(2); }); + + it('renders the author label via the default authorName slot', () => { + render( + + {null} + , + ); + + expect(document.body.textContent).toContain('Alice Author'); + }); + + it('overrides the author label via slots.message.authorName', () => { + function CustomLabel(props: { children?: React.ReactNode }) { + return [{props.children}]; + } + + render( + + + , + ); + + expect(document.querySelector('[data-testid="custom-author-name"]')?.textContent).toBe( + '[Alice]', + ); + }); + + it('hides the author label when slots.message.authorName is null', () => { + render( + + + , + ); + + expect(document.body.textContent).not.toContain('Alice Hidden'); + }); + + it('forwards slots.message.root to ChatMessage instead of replacing the row', () => { + render( + + + , + ); + + // The message body still renders — the root slot only swaps the root element, + // it must not replace the whole ChatMessage row. + expect(screen.getByText('Body text')).not.toBe(null); + // …and the custom element is applied as the message root. + expect(document.querySelector('section.MuiChatMessage-root')).not.toBe(null); + }); }); diff --git a/packages/x-chat/src/ChatMessage/ChatMessageGroup.tsx b/packages/x-chat/src/ChatMessage/ChatMessageGroup.tsx index 5b8548e59a6b3..d1e58cd57384b 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessageGroup.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessageGroup.tsx @@ -3,26 +3,31 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { SxProps, Theme } from '@mui/system'; -import { - MessageGroup, - type MessageGroupProps, - useChatVariant, - useMessage, -} from '@mui/x-chat-headless'; +import { MessageGroup, type MessageGroupProps } from '@mui/x-chat-headless'; import { styled, createUseThemeProps } from '../internals/zero-styled'; import { useChatMessageUtilityClasses, type ChatMessageClasses } from './chatMessageClasses'; -import { ChatMessage } from './ChatMessage'; -import { ChatMessageAvatar } from './ChatMessageAvatar'; -import { ChatMessageContent } from './ChatMessageContent'; -import { ChatMessageMeta } from './ChatMessageMeta'; -import { ChatMessageInlineMeta } from './ChatMessageInlineMeta'; +import { ChatMessage, type ChatMessageSlots, type ChatMessageSlotProps } from './ChatMessage'; const useThemeProps = createUseThemeProps('MuiChatMessageGroup'); -export interface ChatMessageGroupProps extends MessageGroupProps { +export interface ChatMessageGroupSlots { + /** The styled group wrapper element. */ + root?: React.ElementType; + /** Nested slot map forwarded to the inner ChatMessage. */ + message?: Partial; +} + +export interface ChatMessageGroupSlotProps { + root?: Record; + message?: ChatMessageSlotProps; +} + +export interface ChatMessageGroupProps extends Omit { className?: string; sx?: SxProps; classes?: Partial; + slots?: ChatMessageGroupSlots; + slotProps?: ChatMessageGroupSlotProps; } const ChatMessageGroupStyled = styled('div', { @@ -54,21 +59,24 @@ const ChatMessageGroupStyled = styled('div', { gap: 0, width: '100%', marginBlockStart, + // When the avatar slot is hidden, drop the reserved avatar size so any + // descendant relying on the variable (padding, author-name offset) collapses too. + '&.MuiChatMessage-noAvatar': { + '--MuiChatMessage-avatarSize': '0px', + }, }; }); const ChatMessageGroupAuthorNameStyled = styled('div', { name: 'MuiChatMessage', slot: 'GroupAuthorName', -})<{ ownerState?: { authorRole?: string; variant?: string } }>(({ theme, ownerState }) => ({ +})<{ ownerState?: { isOwnMessage?: boolean; variant?: string } }>(({ theme, ownerState }) => ({ fontSize: theme.typography.caption.fontSize, fontWeight: theme.typography.fontWeightMedium, color: (theme.vars || theme).palette.text.secondary, marginBottom: 0, ...(ownerState?.variant === 'compact' ? { - // Compact: author name lives inside the message grid in the "authorName" area. - // It shares a row with the avatar. Flex to push timestamp to the right. gridArea: 'authorName', display: 'flex', alignItems: 'center', @@ -78,8 +86,7 @@ const ChatMessageGroupAuthorNameStyled = styled('div', { color: (theme.vars || theme).palette.primary.main, } : { - // Default: offset by avatar width, user right-aligned - ...(ownerState?.authorRole === 'user' + ...(ownerState?.isOwnMessage ? { textAlign: 'right' as const, paddingInlineEnd: `calc(var(--MuiChatMessage-avatarSize) + ${theme.spacing(2)} + ${theme.spacing(0.5)})`, @@ -90,6 +97,10 @@ const ChatMessageGroupAuthorNameStyled = styled('div', { }), })); +function HiddenAuthorName() { + return null; +} + const ChatMessageGroupTimestampStyled = styled('span', { name: 'MuiChatMessage', slot: 'GroupTimestamp', @@ -101,32 +112,6 @@ const ChatMessageGroupTimestampStyled = styled('span', { flexShrink: 0, })); -/** - * Default content rendered inside ChatMessageGroup when no children are provided. - * Uses the variant-aware styled components (avatar, content bubble, meta). - */ -function ChatMessageGroupDefaultContent({ messageId }: { messageId: string }) { - const variant = useChatVariant(); - const isCompact = variant === 'compact'; - const message = useMessage(messageId); - const isStreaming = message?.status === 'streaming'; - const hasMeta = - Boolean(message?.createdAt) || Boolean(message?.editedAt) || Boolean(message?.status); - // In the default variant, meta is rendered inline inside the bubble. - // Skip it during streaming — there is no timestamp yet, and the streaming state - // is already communicated via the MuiChatMessage-streaming CSS class. - const afterContent = - !isCompact && !isStreaming && hasMeta ? : undefined; - - return ( - - - - {isCompact && } - - ); -} - const ChatMessageGroup = React.forwardRef( function ChatMessageGroup(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiChatMessageGroup' }); @@ -142,15 +127,42 @@ const ChatMessageGroup = React.forwardRef } = props; const classes = useChatMessageUtilityClasses(classesProp); - // When no children are provided, render the default styled message layout. - // This ensures ChatMessageGroup works as a standalone component with proper - // styling for both default and compact variants. + // The inner-message slot map (`slots.message`) flows through to ChatMessage. + // `slots.root` (the group-level slot, handled below) is the group's own styled + // wrapper element. The message-level `root` slot lives inside `slots.message` and + // is interpreted by ChatMessage itself — we must NOT hoist it into the row + // component, otherwise a raw element (e.g. `'section'`) would receive `messageId`/ + // `slots`/`slotProps` it can't read and the message body would disappear. + const innerMessageSlots = slots?.message; + const innerMessageSlotProps = slotProps?.message; + + // Track whether the avatar slot is explicitly nulled — used to mirror the + // `noAvatar` class on the group wrapper so any descendant CSS that reads + // `var(--MuiChatMessage-avatarSize)` can collapse the reserved space. + const hasAvatar = innerMessageSlots?.avatar !== null; + + // `authorName` is rendered by the headless MessageGroup (group-level), but + // consumer-facing it lives under `slots.message.authorName` so all per-row + // overrides cluster in one namespace. Null hides the label entirely. + const authorNameOverride = innerMessageSlots?.authorName; + const AuthorNameSlot = + authorNameOverride === null + ? (HiddenAuthorName as React.ElementType) + : ((authorNameOverride ?? ChatMessageGroupAuthorNameStyled) as React.ElementType); + + // Render priority: + // 1. Explicit `children` (legacy: caller provided their own composition) + // 2. Inner ChatMessage instance with the forwarded nested slot map. The nested + // map carries `root`, which ChatMessage applies to its own styled root (and + // `slotProps.root` is applied there too), so we forward instead of replacing. const resolvedChildren = children ?? (messageId ? ( - - - + ) : null); return ( @@ -159,18 +171,17 @@ const ChatMessageGroup = React.forwardRef messageId={messageId} {...other} slots={{ - group: slots?.group ?? ChatMessageGroupStyled, - authorName: slots?.authorName ?? ChatMessageGroupAuthorNameStyled, - groupTimestamp: slots?.groupTimestamp ?? ChatMessageGroupTimestampStyled, - ...slots, + group: (slots?.root ?? ChatMessageGroupStyled) as React.ElementType, + authorName: AuthorNameSlot, + groupTimestamp: ChatMessageGroupTimestampStyled, }} slotProps={{ - ...slotProps, group: { - className: clsx(classes.group, className), + className: clsx(classes.group, !hasAvatar && classes.noAvatar, className), sx, - ...(slotProps?.group as object), + ...(slotProps?.root ?? {}), } as any, + authorName: innerMessageSlotProps?.authorName as any, }} > {resolvedChildren} diff --git a/packages/x-chat/src/ChatMessage/ChatMessageInlineMeta.tsx b/packages/x-chat/src/ChatMessage/ChatMessageInlineMeta.tsx index d42a3cb0e84b7..24e5cd65af27a 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessageInlineMeta.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessageInlineMeta.tsx @@ -31,8 +31,8 @@ const ChatMessageInlineMetaSpacer = styled('span', { const ChatMessageInlineMetaContainer = styled('span', { name: 'MuiChatMessage', slot: 'InlineMeta', -})<{ ownerState?: { role?: string } }>(({ theme, ownerState }) => { - const isUser = ownerState?.role === 'user'; +})<{ ownerState?: { role?: string; isOwnMessage?: boolean } }>(({ theme, ownerState }) => { + const isOwn = ownerState?.isOwnMessage ?? false; return { position: 'absolute', @@ -47,13 +47,13 @@ const ChatMessageInlineMetaContainer = styled('span', { pointerEvents: 'none', userSelect: 'none', // Light mode: primary.main is dark blue → white meta is readable. - // Dark mode (user): primary.main becomes a lighter blue → switch to dark meta for contrast. - color: isUser ? 'rgba(255,255,255,0.65)' : (theme.vars || theme).palette.text.disabled, - ...(isUser && + // Dark mode (own): primary.main becomes a lighter blue → switch to dark meta for contrast. + color: isOwn ? 'rgba(255,255,255,0.65)' : (theme.vars || theme).palette.text.disabled, + ...(isOwn && theme.applyStyles('dark', { color: 'rgba(0,0,0,0.55)', })), - ...(!isUser && + ...(!isOwn && theme.applyStyles('dark', { color: 'rgba(255,255,255,0.45)', })), diff --git a/packages/x-chat/src/ChatMessage/ChatMessageMeta.tsx b/packages/x-chat/src/ChatMessage/ChatMessageMeta.tsx index d18a5f353492f..643d72a9c2b14 100644 --- a/packages/x-chat/src/ChatMessage/ChatMessageMeta.tsx +++ b/packages/x-chat/src/ChatMessage/ChatMessageMeta.tsx @@ -21,26 +21,28 @@ const ChatMessageMetaStyled = styled('div', { name: 'MuiChatMessage', slot: 'Meta', overridesResolver: (_, styles) => styles.meta, -})<{ ownerState?: { role?: string; variant?: string } }>(({ theme, ownerState }) => ({ - gridArea: 'meta', - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - fontSize: theme.typography.caption.fontSize, - color: (theme.vars || theme).palette.text.disabled, - lineHeight: 1.4, - minHeight: '1.2em', - // Compact: always right-align status + timestamp regardless of role. - // Align to the top of the grid row so it stays at the top when content wraps. - // Default: only right-align for user messages. - ...((ownerState?.variant === 'compact' || ownerState?.role === 'user') && { - justifyContent: 'flex-end', - }), - ...(ownerState?.variant === 'compact' && { - alignSelf: 'start', - whiteSpace: 'nowrap', +})<{ ownerState?: { role?: string; variant?: string; isOwnMessage?: boolean } }>( + ({ theme, ownerState }) => ({ + gridArea: 'meta', + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + fontSize: theme.typography.caption.fontSize, + color: (theme.vars || theme).palette.text.disabled, + lineHeight: 1.4, + minHeight: '1.2em', + // Compact: always right-align status + timestamp regardless of ownership. + // Align to the top of the grid row so it stays at the top when content wraps. + // Default: only right-align for own messages. + ...((ownerState?.variant === 'compact' || ownerState?.isOwnMessage) && { + justifyContent: 'flex-end', + }), + ...(ownerState?.variant === 'compact' && { + alignSelf: 'start', + whiteSpace: 'nowrap', + }), }), -})); +); const ChatMessageStatusStyled = styled('span', { name: 'MuiChatMessage', diff --git a/packages/x-chat/src/ChatMessage/chatMessageClasses.ts b/packages/x-chat/src/ChatMessage/chatMessageClasses.ts index 060a6298f3922..9b31159064038 100644 --- a/packages/x-chat/src/ChatMessage/chatMessageClasses.ts +++ b/packages/x-chat/src/ChatMessage/chatMessageClasses.ts @@ -37,6 +37,8 @@ export interface ChatMessageClasses { streaming: string; /** Applied when the message has an error status */ error: string; + /** Applied when the avatar slot is hidden (`slots.avatar: null`) so the grid drops the avatar track. */ + noAvatar: string; } export type ChatMessageClassKey = keyof ChatMessageClasses; @@ -63,6 +65,7 @@ export const chatMessageClasses: ChatMessageClasses = generateUtilityClasses('Mu 'roleAssistant', 'streaming', 'error', + 'noAvatar', ]); const slots: Record = { @@ -83,6 +86,7 @@ const slots: Record = { roleAssistant: ['roleAssistant'], streaming: ['streaming'], error: ['error'], + noAvatar: ['noAvatar'], }; export const useChatMessageUtilityClasses = ( diff --git a/packages/x-chat/src/ChatMessage/index.ts b/packages/x-chat/src/ChatMessage/index.ts index dc14dffeb975b..e92f94c693f73 100644 --- a/packages/x-chat/src/ChatMessage/index.ts +++ b/packages/x-chat/src/ChatMessage/index.ts @@ -14,6 +14,8 @@ export { ChatMessageGroup } from './ChatMessageGroup'; export type { ChatMessageGroupProps } from './ChatMessageGroup'; export { ChatDateDivider } from './ChatDateDivider'; export type { ChatDateDividerProps } from './ChatDateDivider'; +export { ChatMessageError } from '../ChatMessageError/ChatMessageError'; +export type { ChatMessageErrorProps } from '../ChatMessageError/ChatMessageError'; export { chatMessageClasses, getChatMessageUtilityClass, diff --git a/packages/x-chat/src/ChatMessageError/ChatMessageError.test.tsx b/packages/x-chat/src/ChatMessageError/ChatMessageError.test.tsx new file mode 100644 index 0000000000000..615febbbdd240 --- /dev/null +++ b/packages/x-chat/src/ChatMessageError/ChatMessageError.test.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import { act, createRenderer, fireEvent, screen, waitFor } from '@mui/internal-test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import type { ChatAdapter, ChatMessage } from '@mui/x-chat-headless'; +import { ChatRoot, MessageRoot, useChatStore } from '@mui/x-chat-headless'; +import { ChatMessage as ChatMessageComponent } from '../ChatMessage/ChatMessage'; +import { ChatMessageError } from './ChatMessageError'; + +const { render } = createRenderer(); + +function createAdapter(overrides: Partial = {}): ChatAdapter { + return { + async sendMessage() { + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + }, + ...overrides, + }; +} + +const errorMessage: ChatMessage = { + id: 'm1', + role: 'user', + status: 'error', + parts: [{ type: 'text', text: 'Failed' }], +}; + +const okMessage: ChatMessage = { + id: 'm2', + role: 'user', + status: 'sent', + parts: [{ type: 'text', text: 'Fine' }], +}; + +let storeRef: ReturnType | null = null; +function StoreCapture() { + storeRef = useChatStore(); + return null; +} + +describe('ChatMessageError', () => { + it('renders nothing when there is no error for the message', () => { + render( + + + , + ); + + expect(screen.queryByRole('alert')).to.equal(null); + }); + + it('renders the inline error card with a retry button when the error matches the message', () => { + storeRef = null; + render( + + + + , + ); + + expect(storeRef).not.to.equal(null); + + act(() => { + storeRef!.setMessageError('m1', { + code: 'SEND_ERROR', + message: 'Send failed', + source: 'send', + recoverable: true, + retryable: true, + details: { messageId: 'm1' }, + }); + }); + + const alert = screen.getByRole('alert'); + expect(alert.textContent).to.contain('Send failed'); + expect(screen.getByRole('button', { name: 'Retry' })).not.to.equal(null); + }); + + it('invokes the runtime retry action when the retry button is clicked', async () => { + const sendMessage = vi.fn(async () => { + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + }); + storeRef = null; + render( + + + + , + ); + + act(() => { + storeRef!.setMessageError('m1', { + code: 'SEND_ERROR', + message: 'Send failed', + source: 'send', + recoverable: true, + retryable: true, + details: { messageId: 'm1' }, + }); + }); + + const retryButton = screen.getByRole('button', { name: 'Retry' }); + fireEvent.click(retryButton); + + await waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + }); + + it('disables the retry button while another stream is in flight', () => { + storeRef = null; + render( + + + + , + ); + + act(() => { + storeRef!.setMessageError('m1', { + code: 'SEND_ERROR', + message: 'Send failed', + source: 'send', + recoverable: true, + retryable: true, + details: { messageId: 'm1' }, + }); + storeRef!.setStreaming(true); + }); + + const retryButton = screen.getByRole('button', { name: 'Retry' }); + expect(retryButton).to.have.property('disabled', true); + }); + + it('forwards consumer-supplied classes to the message and retryButton slots', () => { + storeRef = null; + render( + + + + + + , + ); + + act(() => { + storeRef!.setMessageError('m1', { + code: 'SEND_ERROR', + message: 'Send failed', + source: 'send', + recoverable: true, + retryable: true, + details: { messageId: 'm1' }, + }); + }); + + const alert = screen.getByRole('alert'); + expect(alert.classList.contains('custom-root')).to.equal(true); + const messageSpan = alert.querySelector('.custom-message'); + expect(messageSpan).not.to.equal(null); + const retryButton = screen.getByRole('button', { name: 'Retry' }); + expect(retryButton.classList.contains('custom-retry')).to.equal(true); + }); +}); diff --git a/packages/x-chat/src/ChatMessageError/ChatMessageError.tsx b/packages/x-chat/src/ChatMessageError/ChatMessageError.tsx new file mode 100644 index 0000000000000..c946be8875cbe --- /dev/null +++ b/packages/x-chat/src/ChatMessageError/ChatMessageError.tsx @@ -0,0 +1,178 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { SxProps, Theme } from '@mui/system'; +import Button from '@mui/material/Button'; +import { + MessageError, + type MessageErrorProps, + type MessageErrorOwnerState, + useChatLocaleText, + useChatStatus, + useMessage, +} from '@mui/x-chat-headless'; +import { styled, createUseThemeProps } from '../internals/zero-styled'; +import { + useChatMessageErrorUtilityClasses, + type ChatMessageErrorClasses, +} from './chatMessageErrorClasses'; + +const useThemeProps = createUseThemeProps('MuiChatMessageError'); + +function getErrorCardBackground(theme: Theme & { vars?: any }) { + if (theme.vars) { + return `rgba(${theme.vars.palette.error.mainChannel} / 0.08)`; + } + if (theme.palette.mode === 'dark') { + return 'rgba(244, 67, 54, 0.16)'; + } + return 'rgba(244, 67, 54, 0.08)'; +} + +export interface ChatMessageErrorProps extends MessageErrorProps { + className?: string; + sx?: SxProps; + classes?: Partial; +} + +const ChatMessageErrorRoot = styled('div', { + name: 'MuiChatMessageError', + slot: 'Root', + overridesResolver: (_, styles) => styles.root, +})<{ ownerState?: MessageErrorOwnerState }>(({ theme, ownerState }) => ({ + // Inline error card anchored in the message's `error` grid area. + gridArea: 'error', + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginBlock: theme.spacing(0.5), + paddingBlock: theme.spacing(0.75), + paddingInline: theme.spacing(1.25), + maxWidth: '100%', + boxSizing: 'border-box', + border: `1px solid ${(theme.vars || theme).palette.error.main}`, + borderRadius: theme.shape.borderRadius, + color: (theme.vars || theme).palette.error.main, + backgroundColor: getErrorCardBackground(theme), + fontSize: theme.typography.body2.fontSize, + lineHeight: theme.typography.body2.lineHeight, + ...(ownerState?.isOwnMessage && { + justifySelf: 'end', + }), +})); + +const ChatMessageErrorMessage = styled('span', { + name: 'MuiChatMessageError', + slot: 'Message', + overridesResolver: (_, styles) => styles.message, +})({ + flex: 1, + minWidth: 0, + wordBreak: 'break-word', +}); + +const ChatMessageErrorRetryButton = styled(Button, { + name: 'MuiChatMessageError', + slot: 'RetryButton', + overridesResolver: (_, styles) => styles.retryButton, +})(({ theme }) => ({ + flexShrink: 0, + minWidth: 0, + color: (theme.vars || theme).palette.error.main, +})); + +const ChatMessageErrorSlot = React.forwardRef( + function ChatMessageErrorSlot(props, ref) { + const { ownerState, className, children, messageClassName, retryButtonClassName, ...other } = + props as { + ownerState: MessageErrorOwnerState; + className?: string; + children?: React.ReactNode; + messageClassName?: string; + retryButtonClassName?: string; + } & React.HTMLAttributes; + const localeText = useChatLocaleText(); + const message = useMessage(ownerState.messageId); + const { isStreaming } = useChatStatus(); + + // While any stream is in flight, or the message itself is busy, disable the + // retry button to prevent double submissions. + const disabled = + !ownerState.retryable || + isStreaming || + message?.status === 'sending' || + message?.status === 'streaming'; + + return ( + + + {children ?? ownerState.chatError?.message} + + {ownerState.retryable ? ( + { + void ownerState.retry(); + }} + > + {localeText.retryButtonLabel} + + ) : null} + + ); + }, +); + +const ChatMessageError = React.forwardRef( + function ChatMessageError(inProps, ref) { + const props = useThemeProps({ props: inProps, name: 'MuiChatMessageError' }); + const { slots, slotProps, className, classes: classesProp, sx, ...other } = props; + const classes = useChatMessageErrorUtilityClasses(classesProp); + + return ( + + ); + }, +); + +ChatMessageError.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the TypeScript types and run "pnpm proptypes" | + // ---------------------------------------------------------------------- + children: PropTypes.node, + classes: PropTypes.object, + className: PropTypes.string, + slotProps: PropTypes.object, + slots: PropTypes.object, + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export { ChatMessageError }; diff --git a/packages/x-chat/src/ChatMessageError/chatMessageErrorClasses.ts b/packages/x-chat/src/ChatMessageError/chatMessageErrorClasses.ts new file mode 100644 index 0000000000000..9878fe8b35a15 --- /dev/null +++ b/packages/x-chat/src/ChatMessageError/chatMessageErrorClasses.ts @@ -0,0 +1,33 @@ +import generateUtilityClass from '@mui/utils/generateUtilityClass'; +import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; +import composeClasses from '@mui/utils/composeClasses'; + +export interface ChatMessageErrorClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the message text element. */ + message: string; + /** Styles applied to the retry button. */ + retryButton: string; +} + +export type ChatMessageErrorClassKey = keyof ChatMessageErrorClasses; + +export function getChatMessageErrorUtilityClass(slot: string): string { + return generateUtilityClass('MuiChatMessageError', slot); +} + +export const chatMessageErrorClasses: ChatMessageErrorClasses = generateUtilityClasses( + 'MuiChatMessageError', + ['root', 'message', 'retryButton'], +); + +const slots: Record = { + root: ['root'], + message: ['message'], + retryButton: ['retryButton'], +}; + +export const useChatMessageErrorUtilityClasses = ( + classes: Partial | undefined, +): ChatMessageErrorClasses => composeClasses(slots, getChatMessageErrorUtilityClass, classes); diff --git a/packages/x-chat/src/ChatMessageError/index.ts b/packages/x-chat/src/ChatMessageError/index.ts new file mode 100644 index 0000000000000..07df2661576c9 --- /dev/null +++ b/packages/x-chat/src/ChatMessageError/index.ts @@ -0,0 +1,8 @@ +export { ChatMessageError } from './ChatMessageError'; +export type { ChatMessageErrorProps } from './ChatMessageError'; +export { + chatMessageErrorClasses, + getChatMessageErrorUtilityClass, + useChatMessageErrorUtilityClasses, +} from './chatMessageErrorClasses'; +export type { ChatMessageErrorClasses, ChatMessageErrorClassKey } from './chatMessageErrorClasses'; diff --git a/packages/x-chat/src/ChatMessageList/ChatMessageList.test.tsx b/packages/x-chat/src/ChatMessageList/ChatMessageList.test.tsx index 2539865414766..25c3949fe5dad 100644 --- a/packages/x-chat/src/ChatMessageList/ChatMessageList.test.tsx +++ b/packages/x-chat/src/ChatMessageList/ChatMessageList.test.tsx @@ -2,7 +2,10 @@ import * as React from 'react'; import { createRenderer, screen } from '@mui/internal-test-utils'; import { describe, expect, it } from 'vitest'; import type { ChatAdapter } from '@mui/x-chat-headless'; +import { ChatRoot } from '@mui/x-chat-headless'; import { ChatBox } from '../ChatBox/ChatBox'; +import { ChatConversation } from '../ChatConversation/ChatConversation'; +import { ChatMessageList } from './ChatMessageList'; const { render } = createRenderer(); @@ -36,4 +39,102 @@ describe('ChatMessageList', () => { ); expect(screen.getByText('Hello')).not.toBe(null); }); + + it('renders the default row when used standalone without renderItem', () => { + render( + + + + + , + ); + expect(document.querySelector('.MuiChatMessage-group')).not.toBe(null); + expect(document.querySelector('.MuiChatMessage-root')).not.toBe(null); + expect(document.querySelector('.MuiChatMessage-avatar')).not.toBe(null); + expect(document.querySelector('.MuiChatMessage-content')).not.toBe(null); + expect(screen.getByText('Hi')).not.toBe(null); + }); + + it('uses a custom renderItem when provided', () => { + render( + + +
{id}
} /> +
+
, + ); + expect(screen.getByTestId('custom-row').textContent).toBe('m1'); + // Default row chrome should not render when a custom renderItem wins. + expect(document.querySelector('.MuiChatMessage-root')).toBe(null); + }); + + it('honors a row slot override in the default render path', () => { + function CustomAvatar() { + return ; + } + render( + + + + + , + ); + // Custom avatar replaces the default, but the rest of the row chrome stays. + expect(screen.getByTestId('custom-avatar')).not.toBe(null); + expect(document.querySelector('.MuiChatMessage-group')).not.toBe(null); + expect(document.querySelector('.MuiChatMessage-root')).not.toBe(null); + expect(document.querySelector('.MuiChatMessage-content')).not.toBe(null); + }); + + it('groups against the rendered items subset, not the full conversation', () => { + render( + + + + + , + ); + + // Only m2/m3 are rendered. m2 is the first row of the rendered list, so it must be + // treated as the start of a group (author label shown) — it would be wrongly grouped + // against the off-list m1 if grouping ran on the full conversation. + expect(screen.getByText('Two')).not.toBe(null); + expect(screen.queryByText('One')).toBe(null); + expect(document.body.textContent).toContain('Alice Author'); + }); }); diff --git a/packages/x-chat/src/ChatMessageList/ChatMessageList.tsx b/packages/x-chat/src/ChatMessageList/ChatMessageList.tsx index cc313a3532337..445fe43073457 100644 --- a/packages/x-chat/src/ChatMessageList/ChatMessageList.tsx +++ b/packages/x-chat/src/ChatMessageList/ChatMessageList.tsx @@ -6,6 +6,8 @@ import { SxProps, Theme } from '@mui/system'; import { MessageListRoot, type MessageListRootProps, + type MessageListRootSlots, + type MessageListRootSlotProps, type MessageListRootHandle, useChatDensity, } from '@mui/x-chat-headless'; @@ -14,10 +16,33 @@ import { useChatMessageListUtilityClasses, type ChatMessageListClasses, } from './chatMessageListClasses'; +import { + DefaultMessageItem, + type ChatMessageRowSlots, + type ChatMessageRowSlotProps, +} from './DefaultMessageItem'; const useThemeProps = createUseThemeProps('MuiChatMessageList'); -export interface ChatMessageListProps extends MessageListRootProps { +export interface ChatMessageListSlots extends MessageListRootSlots, Partial {} + +export interface ChatMessageListSlotProps + extends MessageListRootSlotProps, ChatMessageRowSlotProps {} + +export interface ChatMessageListProps extends Omit< + MessageListRootProps, + 'renderItem' | 'slots' | 'slotProps' +> { + /** + * Render a custom row for each message. When omitted, the default row used by + * `ChatBox` is rendered (group → message → avatar → content, with conditional + * inline meta, external meta in compact variant, and message actions slot). + * Provide a function to fully replace the row; use slot overrides to tweak + * individual sub-components without losing the default chrome. + */ + renderItem?: MessageListRootProps['renderItem']; + slots?: Partial; + slotProps?: ChatMessageListSlotProps; className?: string; sx?: SxProps; classes?: Partial; @@ -67,38 +92,114 @@ const ChatMessageListContentStyled = styled('div', { }; }); +// The row renderer wants the nested `messagesList` and `message` families. +// Everything else on `slots` belongs to `MessageListRoot` (list-level). +const ROW_SLOT_KEYS: ReadonlyArray = ['messagesList', 'message']; + const ChatMessageList = React.forwardRef( function ChatMessageList(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiChatMessageList' }); - const { slots, slotProps, className, classes: classesProp, sx, ...other } = props; + const { + renderItem: renderItemProp, + slots, + slotProps, + className, + classes: classesProp, + sx, + ...other + } = props; const classes = useChatMessageListUtilityClasses(classesProp); const density = useChatDensity(); + // Partition slots/slotProps: row-level keys go to the default renderer, + // list-level keys go to MessageListRoot. Row-level entries are extracted + // even when a custom renderItem is provided so they don't get forwarded to + // MessageListRoot — which would reject unknown slot keys at runtime. + const { rowSlots, listSlots } = React.useMemo(() => { + const row: Partial = {}; + const list: Partial = {}; + if (slots) { + for (const key of Object.keys(slots) as Array) { + if ((ROW_SLOT_KEYS as ReadonlyArray).includes(key)) { + (row as any)[key] = slots[key]; + } else { + (list as any)[key] = slots[key]; + } + } + } + return { rowSlots: row, listSlots: list }; + }, [slots]); + + const { rowSlotProps, listSlotProps } = React.useMemo(() => { + const row: ChatMessageRowSlotProps = {}; + const list: MessageListRootSlotProps = {}; + if (slotProps) { + for (const key of Object.keys(slotProps) as Array) { + if ((ROW_SLOT_KEYS as ReadonlyArray).includes(key)) { + (row as any)[key] = slotProps[key]; + } else { + (list as any)[key] = slotProps[key]; + } + } + } + return { rowSlotProps: row, listSlotProps: list }; + }, [slotProps]); + + // Keep the default renderer stable; read latest slot overrides and the resolved + // `items` from refs so updates don't churn the virtualized list. `items` is read + // without destructuring so it still flows to MessageListRoot via `...other`. + const rowSlotsRef = React.useRef(rowSlots); + const rowSlotPropsRef = React.useRef(rowSlotProps); + const itemsRef = React.useRef(undefined); + rowSlotsRef.current = rowSlots; + rowSlotPropsRef.current = rowSlotProps; + itemsRef.current = (other as { items?: string[] }).items; + + // Forward `index` (rendered-list relative) and the rendered `items` so the group + // computes grouping against the rendered list — otherwise a custom `items` subset + // would regroup against the full conversation and drop avatars/author labels. + const defaultRenderItem = React.useCallback( + ({ id, index }: { id: string; index: number }) => ( + + ), + [], + ); + + const renderItem = renderItemProp ?? defaultRenderItem; + return ( @@ -136,7 +237,14 @@ ChatMessageList.propTypes = { items: PropTypes.arrayOf(PropTypes.string), onReachTop: PropTypes.func, overlay: PropTypes.node, - renderItem: PropTypes.func.isRequired, + /** + * Render a custom row for each message. When omitted, the default row used by + * `ChatBox` is rendered (group → message → avatar → content, with conditional + * inline meta, external meta in compact variant, and message actions slot). + * Provide a function to fully replace the row; use slot overrides to tweak + * individual sub-components without losing the default chrome. + */ + renderItem: PropTypes.func, slotProps: PropTypes.object, slots: PropTypes.object, sx: PropTypes.oneOfType([ diff --git a/packages/x-chat/src/ChatMessageList/DefaultMessageItem.tsx b/packages/x-chat/src/ChatMessageList/DefaultMessageItem.tsx new file mode 100644 index 0000000000000..01f3eaafcacba --- /dev/null +++ b/packages/x-chat/src/ChatMessageList/DefaultMessageItem.tsx @@ -0,0 +1,77 @@ +'use client'; +import * as React from 'react'; +import { ChatMessageGroup } from '../ChatMessage/ChatMessageGroup'; +import type { + ChatBoxMessageSlots, + ChatBoxMessageSlotProps, + ChatBoxMessagesListSlots, + ChatBoxMessagesListSlotProps, +} from '../ChatBox/ChatBox.types'; + +/** + * Per-row slot interface shared by `ChatBox` and `ChatMessageList`. + * + * Reuses ChatBox's nested message-list / message families so the same + * slot path works at both levels. + */ +export interface ChatMessageRowSlots { + messagesList?: ChatBoxMessagesListSlots; + message?: ChatBoxMessageSlots; +} + +export interface ChatMessageRowSlotProps { + messagesList?: ChatBoxMessagesListSlotProps; + message?: ChatBoxMessageSlotProps; +} + +export interface DefaultMessageItemProps { + id: string; + /** + * Index of this row within the rendered list. Forwarded to the group so grouping + * (previous/next neighbor lookup) is computed against the rendered list rather than + * the full conversation when a custom `items` subset is used. + */ + index?: number; + /** The rendered list's message ids. Forwarded to the group alongside `index`. */ + items?: string[]; + slots?: ChatMessageRowSlots; + slotProps?: ChatMessageRowSlotProps; +} + +/** + * Default message-row composition used by `ChatBox` and `ChatMessageList` + * when no custom `renderItem` is provided. + * + * `slots.messagesList.group` swaps the group component itself. + * `slots.message` forwards (as the nested map) into the chosen group so it + * can hand it down to the inner `ChatMessage`. Presentational `null` slots + * collapse layout as before. + * + * Memoized because this is the per-row component inside the virtualized list: + * when scroll-driven re-renders pass the same id/slots/slotProps references + * (the parent reads them from refs), the shallow compare short-circuits. + */ +export const DefaultMessageItem = React.memo(function DefaultMessageItem({ + id, + index, + items, + slots, + slotProps, +}: DefaultMessageItemProps) { + const GroupSlot = (slots?.messagesList?.group ?? ChatMessageGroup) as typeof ChatMessageGroup; + + return ( + + ); +}); diff --git a/packages/x-chat/src/ChatMessageSkeleton/ChatMessageSkeleton.tsx b/packages/x-chat/src/ChatMessageSkeleton/ChatMessageSkeleton.tsx index 046b569c2ce71..c2e1b2b74cf2d 100644 --- a/packages/x-chat/src/ChatMessageSkeleton/ChatMessageSkeleton.tsx +++ b/packages/x-chat/src/ChatMessageSkeleton/ChatMessageSkeleton.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { keyframes } from '@mui/system'; +import { keyframes, SxProps, Theme } from '@mui/system'; import useSlotProps from '@mui/utils/useSlotProps'; import type { SlotComponentProps } from '@mui/utils/types'; import { styled, createUseThemeProps } from '../internals/zero-styled'; @@ -68,6 +68,7 @@ export interface ChatMessageSkeletonProps { */ lines?: number; className?: string; + sx?: SxProps; classes?: Partial; slots?: ChatMessageSkeletonSlots; slotProps?: ChatMessageSkeletonSlotProps; @@ -82,7 +83,7 @@ const ChatMessageSkeleton = React.forwardRef(function ChatMessageSkeleton( ref: React.Ref, ) { const props = useThemeProps({ props: inProps, name: 'MuiChatMessageSkeleton' }); - const { lines = 3, className, classes: classesProp, slots, slotProps, ...other } = props; + const { lines = 3, className, classes: classesProp, slots, slotProps, sx, ...other } = props; const classes = useChatMessageSkeletonUtilityClasses(classesProp); const Root = slots?.root ?? ChatMessageSkeletonRootStyled; @@ -96,6 +97,7 @@ const ChatMessageSkeleton = React.forwardRef(function ChatMessageSkeleton( additionalProps: { ref, className: clsx(classes.root, className), + sx, }, }); @@ -124,6 +126,11 @@ ChatMessageSkeleton.propTypes = { lines: PropTypes.number, slotProps: PropTypes.object, slots: PropTypes.object, + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), } as any; export { ChatMessageSkeleton }; diff --git a/packages/x-chat/src/ChatMessageSources/ChatMessageSources.tsx b/packages/x-chat/src/ChatMessageSources/ChatMessageSources.tsx index 1f72d03ee105c..b074fc1ad8cdb 100644 --- a/packages/x-chat/src/ChatMessageSources/ChatMessageSources.tsx +++ b/packages/x-chat/src/ChatMessageSources/ChatMessageSources.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; +import { SxProps, Theme } from '@mui/system'; import useSlotProps from '@mui/utils/useSlotProps'; import type { SlotComponentProps } from '@mui/utils/types'; import { styled, createUseThemeProps } from '../internals/zero-styled'; @@ -71,6 +72,7 @@ export interface ChatMessageSourcesProps { label?: string; children?: React.ReactNode; className?: string; + sx?: SxProps; classes?: Partial; slots?: ChatMessageSourcesSlots; slotProps?: ChatMessageSourcesSlotProps; @@ -92,6 +94,7 @@ const ChatMessageSources = React.forwardRef(function ChatMessageSources( classes: classesProp, slots, slotProps, + sx, ...other } = props; const classes = useChatMessageSourcesUtilityClasses(classesProp); @@ -108,6 +111,7 @@ const ChatMessageSources = React.forwardRef(function ChatMessageSources( additionalProps: { ref, className: clsx(classes.root, className), + sx, }, }); @@ -152,6 +156,11 @@ ChatMessageSources.propTypes = { label: PropTypes.string, slotProps: PropTypes.object, slots: PropTypes.object, + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), } as any; export { ChatMessageSources }; diff --git a/packages/x-chat/src/index.ts b/packages/x-chat/src/index.ts index 48421e92668bd..4d2f3e269a691 100644 --- a/packages/x-chat/src/index.ts +++ b/packages/x-chat/src/index.ts @@ -116,6 +116,18 @@ export type { ChatDateDividerProps } from './ChatMessage/ChatDateDivider'; export { chatMessageClasses, getChatMessageUtilityClass } from './ChatMessage/chatMessageClasses'; export type { ChatMessageClasses, ChatMessageClassKey } from './ChatMessage/chatMessageClasses'; +// ─── ChatMessageError ───────────────────────────────────────────────────────── +export { ChatMessageError } from './ChatMessageError/ChatMessageError'; +export type { ChatMessageErrorProps } from './ChatMessageError/ChatMessageError'; +export { + chatMessageErrorClasses, + getChatMessageErrorUtilityClass, +} from './ChatMessageError/chatMessageErrorClasses'; +export type { + ChatMessageErrorClasses, + ChatMessageErrorClassKey, +} from './ChatMessageError/chatMessageErrorClasses'; + // ─── ChatMessageList ────────────────────────────────────────────────────────── export { ChatMessageList } from './ChatMessageList/ChatMessageList'; export type { ChatMessageListProps } from './ChatMessageList/ChatMessageList'; diff --git a/packages/x-chat/src/internals/useCopyToClipboard.ts b/packages/x-chat/src/internals/useCopyToClipboard.ts new file mode 100644 index 0000000000000..7b16701384e00 --- /dev/null +++ b/packages/x-chat/src/internals/useCopyToClipboard.ts @@ -0,0 +1,51 @@ +'use client'; +import * as React from 'react'; + +export type CopyState = 'idle' | 'copied'; + +export interface UseCopyToClipboardResult { + copyState: CopyState; + copy: (value: string) => void; +} + +export function useCopyToClipboard(resetMs: number = 2000): UseCopyToClipboardResult { + const [copyState, setCopyState] = React.useState('idle'); + const resetTimerRef = React.useRef | null>(null); + + React.useEffect(() => { + return () => { + if (resetTimerRef.current !== null) { + clearTimeout(resetTimerRef.current); + } + }; + }, []); + + const copy = React.useCallback( + (value: string) => { + // Guard against environments without the async Clipboard API + // (older browsers, insecure contexts, some test runners). + if ( + typeof navigator === 'undefined' || + typeof navigator.clipboard?.writeText !== 'function' + ) { + return; + } + + navigator.clipboard.writeText(value).then( + () => { + setCopyState('copied'); + if (resetTimerRef.current !== null) { + clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = setTimeout(() => setCopyState('idle'), resetMs); + }, + () => { + // Clipboard write failed (e.g. permissions denied) — no-op + }, + ); + }, + [resetMs], + ); + + return { copyState, copy }; +} diff --git a/packages/x-chat/src/themeAugmentation/components.ts b/packages/x-chat/src/themeAugmentation/components.ts index 765b9d7c09588..2f7b5b3e6e65d 100644 --- a/packages/x-chat/src/themeAugmentation/components.ts +++ b/packages/x-chat/src/themeAugmentation/components.ts @@ -21,6 +21,11 @@ export interface ChatComponents { styleOverrides?: ComponentsOverrides['MuiChatMessage']; variants?: ComponentsVariants['MuiChatMessage']; }; + MuiChatMessageError?: { + defaultProps?: ComponentsProps['MuiChatMessageError']; + styleOverrides?: ComponentsOverrides['MuiChatMessageError']; + variants?: ComponentsVariants['MuiChatMessageError']; + }; MuiChatMessageList?: { defaultProps?: ComponentsProps['MuiChatMessageList']; styleOverrides?: ComponentsOverrides['MuiChatMessageList']; diff --git a/packages/x-chat/src/themeAugmentation/overrides.ts b/packages/x-chat/src/themeAugmentation/overrides.ts index 7da80555f255f..9b3c7de73bca4 100644 --- a/packages/x-chat/src/themeAugmentation/overrides.ts +++ b/packages/x-chat/src/themeAugmentation/overrides.ts @@ -2,6 +2,7 @@ import { type ChatCodeBlockClassKey } from '../ChatCodeBlock/chatCodeBlockClasse import { type ChatConfirmationClassKey } from '../ChatConfirmation/chatConfirmationClasses'; import { type ChatBoxClassKey } from '../ChatBox/chatBoxClasses'; import { type ChatMessageClassKey } from '../ChatMessage/chatMessageClasses'; +import { type ChatMessageErrorClassKey } from '../ChatMessageError/chatMessageErrorClasses'; import { type ChatMessageListClassKey } from '../ChatMessageList/chatMessageListClasses'; import { type ChatConversationClassKey } from '../ChatConversation/chatConversationClasses'; import { type ChatComposerClassKey } from '../ChatComposer/chatComposerClasses'; @@ -20,6 +21,7 @@ export interface ChatComponentNameToClassKey { MuiChatConfirmation: ChatConfirmationClassKey; MuiChatBox: ChatBoxClassKey; MuiChatMessage: ChatMessageClassKey; + MuiChatMessageError: ChatMessageErrorClassKey; MuiChatMessageList: ChatMessageListClassKey; MuiChatConversation: ChatConversationClassKey; MuiChatComposer: ChatComposerClassKey; diff --git a/packages/x-chat/src/themeAugmentation/props.ts b/packages/x-chat/src/themeAugmentation/props.ts index d1afbe8c56507..48dc8eaff3960 100644 --- a/packages/x-chat/src/themeAugmentation/props.ts +++ b/packages/x-chat/src/themeAugmentation/props.ts @@ -2,6 +2,7 @@ import { type ChatCodeBlockProps } from '../ChatCodeBlock/ChatCodeBlock'; import { type ChatConfirmationProps } from '../ChatConfirmation/ChatConfirmation'; import { type ChatBoxProps } from '../ChatBox/ChatBox.types'; import { type ChatMessageProps } from '../ChatMessage/ChatMessage'; +import { type ChatMessageErrorProps } from '../ChatMessageError/ChatMessageError'; import { type ChatMessageListProps } from '../ChatMessageList/ChatMessageList'; import { type ChatConversationProps } from '../ChatConversation/ChatConversation'; import { type ChatComposerProps } from '../ChatComposer/ChatComposer'; @@ -19,6 +20,7 @@ export interface ChatComponentsPropsList { MuiChatConfirmation: ChatConfirmationProps; MuiChatBox: ChatBoxProps; MuiChatMessage: ChatMessageProps; + MuiChatMessageError: ChatMessageErrorProps; MuiChatMessageList: ChatMessageListProps; MuiChatConversation: ChatConversationProps; MuiChatComposer: ChatComposerProps; diff --git a/scripts/buildApiDocs/chatSettings/getComponentInfo.ts b/scripts/buildApiDocs/chatSettings/getComponentInfo.ts index 56df0e43df33b..c6486e6e7be3a 100644 --- a/scripts/buildApiDocs/chatSettings/getComponentInfo.ts +++ b/scripts/buildApiDocs/chatSettings/getComponentInfo.ts @@ -92,8 +92,10 @@ export function getComponentImports(name: string, filename: string) { } } - return [ - `import { ${name} } from '${subdirectoryImportPath}';`, - `import { ${name} } from '${rootImportPath}';`, - ]; + return Array.from( + new Set([ + `import { ${name} } from '${subdirectoryImportPath}';`, + `import { ${name} } from '${rootImportPath}';`, + ]), + ); } diff --git a/scripts/buildApiDocs/chatSettings/index.ts b/scripts/buildApiDocs/chatSettings/index.ts index c1b4d85672d3e..169d201e9ceae 100644 --- a/scripts/buildApiDocs/chatSettings/index.ts +++ b/scripts/buildApiDocs/chatSettings/index.ts @@ -96,6 +96,8 @@ export default chatApiPages; 'suggestions/SuggestionsRoot.tsx', // Internal implementation detail of ChatBox, not exported from the package index. 'ChatBox/ChatBoxContent.tsx', + // Default row builder shared by ChatBox and ChatMessageList, not part of the public API. + 'ChatMessageList/DefaultMessageItem.tsx', // Internal default icon components, not intended as public API. 'icons/DefaultAttachIcon.tsx', 'icons/DefaultCloseIcon.tsx', diff --git a/scripts/x-chat.exports.json b/scripts/x-chat.exports.json index 9769f53900827..3760c1326af47 100644 --- a/scripts/x-chat.exports.json +++ b/scripts/x-chat.exports.json @@ -76,6 +76,11 @@ { "name": "ChatMessageClassKey", "kind": "TypeAlias" }, { "name": "ChatMessageContent", "kind": "Variable" }, { "name": "ChatMessageContentProps", "kind": "Interface" }, + { "name": "ChatMessageError", "kind": "Variable" }, + { "name": "chatMessageErrorClasses", "kind": "Variable" }, + { "name": "ChatMessageErrorClasses", "kind": "Interface" }, + { "name": "ChatMessageErrorClassKey", "kind": "TypeAlias" }, + { "name": "ChatMessageErrorProps", "kind": "Interface" }, { "name": "ChatMessageGroup", "kind": "Variable" }, { "name": "ChatMessageGroupProps", "kind": "Interface" }, { "name": "ChatMessageInlineMeta", "kind": "Function" }, @@ -142,6 +147,7 @@ { "name": "getChatConfirmationUtilityClass", "kind": "Function" }, { "name": "getChatConversationListUtilityClass", "kind": "Function" }, { "name": "getChatConversationUtilityClass", "kind": "Function" }, + { "name": "getChatMessageErrorUtilityClass", "kind": "Function" }, { "name": "getChatMessageListUtilityClass", "kind": "Function" }, { "name": "getChatMessageSkeletonUtilityClass", "kind": "Function" }, { "name": "getChatMessageSourcesUtilityClass", "kind": "Function" },