From 5a87f8e422a49c67637a28e0e309fd4b269f423a Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 6 Jun 2025 10:13:31 +0200 Subject: [PATCH 1/4] Split the chat-messages module --- .../jupyter-chat/src/components/avatar.tsx | 68 ++ .../src/components/chat-messages.tsx | 739 ------------------ packages/jupyter-chat/src/components/chat.tsx | 6 +- packages/jupyter-chat/src/components/index.ts | 4 +- .../src/components/messages/footer.tsx | 5 +- .../src/components/messages/header.tsx | 133 ++++ .../src/components/messages/index.ts | 14 + .../components/messages/message-renderer.tsx | 2 +- .../src/components/messages/message.tsx | 156 ++++ .../src/components/messages/messages.tsx | 219 ++++++ .../src/components/messages/navigation.tsx | 167 ++++ .../src/components/{ => messages}/toolbar.tsx | 0 .../src/components/messages/welcome.tsx | 1 + .../src/components/messages/writers.tsx | 51 ++ 14 files changed, 819 insertions(+), 746 deletions(-) create mode 100644 packages/jupyter-chat/src/components/avatar.tsx delete mode 100644 packages/jupyter-chat/src/components/chat-messages.tsx create mode 100644 packages/jupyter-chat/src/components/messages/header.tsx create mode 100644 packages/jupyter-chat/src/components/messages/index.ts create mode 100644 packages/jupyter-chat/src/components/messages/message.tsx create mode 100644 packages/jupyter-chat/src/components/messages/messages.tsx create mode 100644 packages/jupyter-chat/src/components/messages/navigation.tsx rename packages/jupyter-chat/src/components/{ => messages}/toolbar.tsx (100%) create mode 100644 packages/jupyter-chat/src/components/messages/writers.tsx diff --git a/packages/jupyter-chat/src/components/avatar.tsx b/packages/jupyter-chat/src/components/avatar.tsx new file mode 100644 index 00000000..471dd26f --- /dev/null +++ b/packages/jupyter-chat/src/components/avatar.tsx @@ -0,0 +1,68 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Avatar as MuiAvatar, Typography } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material'; +import React from 'react'; + +import { IUser } from '../types'; + +/** + * The avatar props. + */ +type AvatarProps = { + /** + * The user to display an avatar. + */ + user: IUser; + /** + * Whether the avatar should be small. + */ + small?: boolean; +}; + +/** + * The avatar component. + */ +export function Avatar(props: AvatarProps): JSX.Element | null { + const { user } = props; + + const sharedStyles: SxProps = { + height: `${props.small ? '16' : '24'}px`, + width: `${props.small ? '16' : '24'}px`, + bgcolor: user.color, + fontSize: `var(--jp-ui-font-size${props.small ? '0' : '1'})` + }; + + const name = + user.display_name ?? user.name ?? (user.username || 'User undefined'); + return user.avatar_url ? ( + + ) : user.initials ? ( + + + {user.initials} + + + ) : null; +} diff --git a/packages/jupyter-chat/src/components/chat-messages.tsx b/packages/jupyter-chat/src/components/chat-messages.tsx deleted file mode 100644 index b4898b4b..00000000 --- a/packages/jupyter-chat/src/components/chat-messages.tsx +++ /dev/null @@ -1,739 +0,0 @@ -/* - * Copyright (c) Jupyter Development Team. - * Distributed under the terms of the Modified BSD License. - */ - -import { Button } from '@jupyter/react-components'; -import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; -import { - LabIcon, - caretDownEmptyIcon, - classes -} from '@jupyterlab/ui-components'; -import { PromiseDelegate } from '@lumino/coreutils'; -import { Avatar as MuiAvatar, Box, Typography } from '@mui/material'; -import type { SxProps, Theme } from '@mui/material'; -import clsx from 'clsx'; -import React, { useEffect, useState, useRef, forwardRef } from 'react'; - -import { AttachmentPreviewList } from './attachments'; -import { ChatInput } from './chat-input'; -import { IInputToolbarRegistry } from './input'; -import { MessageFooter } from './messages/footer'; -import { MessageRenderer } from './messages/message-renderer'; -import { WelcomeMessage } from './messages/welcome'; -import { ScrollContainer } from './scroll-container'; -import { IChatCommandRegistry } from '../chat-commands'; -import { IMessageFooterRegistry } from '../footers'; -import { IInputModel, InputModel } from '../input-model'; -import { IChatModel } from '../model'; -import { IChatMessage, IUser } from '../types'; -import { replaceSpanToMention } from '../utils'; - -const MESSAGES_BOX_CLASS = 'jp-chat-messages-container'; -const MESSAGE_CLASS = 'jp-chat-message'; -const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked'; -const MESSAGE_HEADER_CLASS = 'jp-chat-message-header'; -const MESSAGE_TIME_CLASS = 'jp-chat-message-time'; -const WRITERS_CLASS = 'jp-chat-writers'; -const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation'; -const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread'; -const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top'; -const NAVIGATION_BOTTOM_CLASS = 'jp-chat-navigation-bottom'; - -/** - * The base components props. - */ -type BaseMessageProps = { - /** - * The mime renderer registry. - */ - rmRegistry: IRenderMimeRegistry; - /** - * The chat model. - */ - model: IChatModel; - /** - * The chat commands registry. - */ - chatCommandRegistry?: IChatCommandRegistry; - /** - * The input toolbar registry. - */ - inputToolbarRegistry: IInputToolbarRegistry; - /** - * The footer registry. - */ - messageFooterRegistry?: IMessageFooterRegistry; - /** - * The welcome message. - */ - welcomeMessage?: string; -}; - -/** - * The messages list component. - */ -export function ChatMessages(props: BaseMessageProps): JSX.Element { - const { model } = props; - const [messages, setMessages] = useState(model.messages); - const refMsgBox = useRef(null); - const [currentWriters, setCurrentWriters] = useState([]); - const [allRendered, setAllRendered] = useState(false); - - // The list of message DOM and their rendered promises. - const listRef = useRef<(HTMLDivElement | null)[]>([]); - const renderedPromise = useRef[]>([]); - - /** - * Effect: fetch history and config on initial render - */ - useEffect(() => { - async function fetchHistory() { - if (!model.getHistory) { - return; - } - model - .getHistory() - .then(history => setMessages(history.messages)) - .catch(e => console.error(e)); - } - - fetchHistory(); - setCurrentWriters([]); - }, [model]); - - /** - * Effect: listen to chat messages. - */ - useEffect(() => { - function handleChatEvents() { - setMessages([...model.messages]); - } - - function handleWritersChange(_: IChatModel, writers: IChatModel.IWriter[]) { - setCurrentWriters(writers.map(writer => writer.user)); - } - - model.messagesUpdated.connect(handleChatEvents); - model.writersChanged?.connect(handleWritersChange); - - return function cleanup() { - model.messagesUpdated.disconnect(handleChatEvents); - model.writersChanged?.disconnect(handleChatEvents); - }; - }, [model]); - - /** - * Observe the messages to update the current viewport and the unread messages. - */ - useEffect(() => { - const observer = new IntersectionObserver(entries => { - // Used on first rendering, to ensure all the message as been rendered once. - if (!allRendered) { - Promise.all(renderedPromise.current.map(p => p.promise)).then(() => { - setAllRendered(true); - }); - } - - const unread = [...model.unreadMessages]; - let unreadModified = false; - const inViewport = [...(model.messagesInViewport ?? [])]; - entries.forEach(entry => { - const index = parseInt(entry.target.getAttribute('data-index') ?? ''); - if (!isNaN(index)) { - const viewportIdx = inViewport.indexOf(index); - if (!entry.isIntersecting && viewportIdx !== -1) { - inViewport.splice(viewportIdx, 1); - } else if (entry.isIntersecting && viewportIdx === -1) { - inViewport.push(index); - } - if (unread.length) { - const unreadIdx = unread.indexOf(index); - if (unreadIdx !== -1 && entry.isIntersecting) { - unread.splice(unreadIdx, 1); - unreadModified = true; - } - } - } - }); - - props.model.messagesInViewport = inViewport; - - // Ensure that all messages are rendered before updating unread messages, otherwise - // it can lead to wrong assumption , because more message are in the viewport - // before they are rendered. - if (allRendered && unreadModified) { - model.unreadMessages = unread; - } - }); - - /** - * Observe the messages. - */ - listRef.current.forEach(item => { - if (item) { - observer.observe(item); - } - }); - - return () => { - listRef.current.forEach(item => { - if (item) { - observer.unobserve(item); - } - }); - }; - }, [messages, allRendered]); - - return ( - <> - - {props.welcomeMessage && ( - - )} - - {messages.map((message, i) => { - renderedPromise.current[i] = new PromiseDelegate(); - return ( - // extra div needed to ensure each bubble is on a new line - - - (listRef.current[i] = el)} - /> - {props.messageFooterRegistry && ( - - )} - - ); - })} - - - - - - ); -} - -/** - * The message header props. - */ -type ChatMessageHeaderProps = { - /** - * The chat message. - */ - message: IChatMessage; -}; - -/** - * The message header component. - */ -export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element { - const [datetime, setDatetime] = useState>({}); - const message = props.message; - const sender = message.sender; - /** - * Effect: update cached datetime strings upon receiving a new message. - */ - useEffect(() => { - if (!datetime[message.time]) { - const newDatetime: Record = {}; - let datetime: string; - const currentDate = new Date(); - const sameDay = (date: Date) => - date.getFullYear() === currentDate.getFullYear() && - date.getMonth() === currentDate.getMonth() && - date.getDate() === currentDate.getDate(); - - const msgDate = new Date(message.time * 1000); // Convert message time to milliseconds - - // Display only the time if the day of the message is the current one. - if (sameDay(msgDate)) { - // Use the browser's default locale - datetime = msgDate.toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit' - }); - } else { - // Use the browser's default locale - datetime = msgDate.toLocaleString([], { - day: 'numeric', - month: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit' - }); - } - newDatetime[message.time] = datetime; - setDatetime(newDatetime); - } - }); - - const avatar = message.stacked ? null : Avatar({ user: sender }); - - const name = - sender.display_name ?? sender.name ?? (sender.username || 'User undefined'); - - return ( - :not(:last-child)': { - marginRight: 3 - }, - marginBottom: message.stacked ? '0px' : '12px' - }} - > - {avatar} - - - {!message.stacked && ( - - {name} - - )} - {(message.deleted || message.edited) && ( - - {message.deleted ? '(message deleted)' : '(edited)'} - - )} - - - {`${datetime[message.time]}${message.raw_time ? '*' : ''}`} - - - - ); -} - -/** - * The message component props. - */ -type ChatMessageProps = BaseMessageProps & { - /** - * The message to display. - */ - message: IChatMessage; - /** - * The index of the message in the list. - */ - index: number; - /** - * The promise to resolve when the message is rendered. - */ - renderedPromise: PromiseDelegate; -}; - -/** - * The message component body. - */ -export const ChatMessage = forwardRef( - (props, ref): JSX.Element => { - const { message, model, rmRegistry } = props; - const [edit, setEdit] = useState(false); - const [deleted, setDeleted] = useState(false); - const [canEdit, setCanEdit] = useState(false); - const [canDelete, setCanDelete] = useState(false); - - // Look if the message can be deleted or edited. - useEffect(() => { - // Init canDelete and canEdit state. - setDeleted(message.deleted ?? false); - if (model.user !== undefined && !message.deleted) { - if (model.user.username === message.sender.username) { - setCanEdit(model.updateMessage !== undefined); - setCanDelete(model.deleteMessage !== undefined); - return; - } - if (message.sender.bot) { - setCanDelete(model.deleteMessage !== undefined); - } - } else { - setCanEdit(false); - setCanDelete(false); - } - }, [model, message]); - - // Create an input model only if the message is edited. - const startEdition = (): void => { - if (!canEdit) { - return; - } - let body = message.body; - message.mentions?.forEach(user => { - body = replaceSpanToMention(body, user); - }); - const inputModel = new InputModel({ - chatContext: model.createChatContext(), - onSend: (input: string, model?: IInputModel) => - updateMessage(message.id, input, model), - onCancel: () => cancelEdition(), - value: body, - activeCellManager: model.activeCellManager, - selectionWatcher: model.selectionWatcher, - documentManager: model.documentManager, - config: { - sendWithShiftEnter: model.config.sendWithShiftEnter - }, - attachments: message.attachments, - mentions: message.mentions - }); - model.addEditionModel(message.id, inputModel); - setEdit(true); - }; - - // Cancel the current edition of the message. - const cancelEdition = (): void => { - model.getEditionModel(message.id)?.dispose(); - setEdit(false); - }; - - // Update the content of the message. - const updateMessage = ( - id: string, - input: string, - inputModel?: IInputModel - ): void => { - if (!canEdit || !inputModel) { - return; - } - // Update the message - const updatedMessage = { ...message }; - updatedMessage.body = input; - updatedMessage.attachments = inputModel.attachments; - updatedMessage.mentions = inputModel.mentions; - model.updateMessage!(id, updatedMessage); - model.getEditionModel(message.id)?.dispose(); - setEdit(false); - }; - - // Delete the message. - const deleteMessage = (id: string): void => { - if (!canDelete) { - return; - } - model.deleteMessage!(id); - }; - - // Empty if the message has been deleted. - return deleted ? ( -
- ) : ( -
- {edit && canEdit && model.getEditionModel(message.id) ? ( - cancelEdition()} - model={model.getEditionModel(message.id)!} - chatCommandRegistry={props.chatCommandRegistry} - toolbarRegistry={props.inputToolbarRegistry} - /> - ) : ( - deleteMessage(message.id) : undefined} - rendered={props.renderedPromise} - /> - )} - {message.attachments && !edit && ( - // Display the attachments only if message is not edited, otherwise the - // input component display them. - - )} -
- ); - } -); - -/** - * The writers component props. - */ -type writersProps = { - /** - * The list of users currently writing. - */ - writers: IUser[]; -}; - -/** - * The writers component, displaying the current writers. - */ -export function Writers(props: writersProps): JSX.Element | null { - const { writers } = props; - return writers.length > 0 ? ( - - {writers.map((writer, index) => ( -
- - - {writer.display_name ?? - writer.name ?? - (writer.username || 'User undefined')} - - - {index < writers.length - 1 - ? index < writers.length - 2 - ? ', ' - : ' and ' - : ''} - -
- ))} - {(writers.length > 1 ? ' are' : ' is') + ' writing'} -
- ) : null; -} - -/** - * The navigation component props. - */ -type NavigationProps = BaseMessageProps & { - /** - * The reference to the messages container. - */ - refMsgBox: React.RefObject; - /** - * Whether all the messages has been rendered once on first display. - */ - allRendered: boolean; -}; - -/** - * The navigation component, to navigate to unread messages. - */ -export function Navigation(props: NavigationProps): JSX.Element { - const { model } = props; - const [lastInViewport, setLastInViewport] = useState(true); - const [unreadBefore, setUnreadBefore] = useState(null); - const [unreadAfter, setUnreadAfter] = useState(null); - - const gotoMessage = (msgIdx: number, alignToTop: boolean = true) => { - props.refMsgBox.current?.children.item(msgIdx)?.scrollIntoView(alignToTop); - }; - - // Listen for change in unread messages, and find the first unread message before or - // after the current viewport, to display navigation buttons. - useEffect(() => { - // Do not attempt to display navigation until messages are rendered, it can lead to - // wrong assumption, because more messages are in the viewport before they are - // rendered. - if (!props.allRendered) { - return; - } - - const unreadChanged = (model: IChatModel, unreadIndexes: number[]) => { - const viewport = model.messagesInViewport; - if (!viewport) { - return; - } - - // Initialize the next values with the current values if there still relevant. - let before = - unreadBefore !== null && - unreadIndexes.includes(unreadBefore) && - unreadBefore < Math.min(...viewport) - ? unreadBefore - : null; - - let after = - unreadAfter !== null && - unreadIndexes.includes(unreadAfter) && - unreadAfter > Math.max(...viewport) - ? unreadAfter - : null; - - unreadIndexes.forEach(unread => { - if (viewport?.includes(unread)) { - return; - } - if (unread < (before ?? Math.min(...viewport))) { - before = unread; - } else if ( - unread > Math.max(...viewport) && - unread < (after ?? model.messages.length) - ) { - after = unread; - } - }); - - setUnreadBefore(before); - setUnreadAfter(after); - }; - - model.unreadChanged?.connect(unreadChanged); - - unreadChanged(model, model.unreadMessages); - - // Move to the last the message after all the messages have been first rendered. - gotoMessage(model.messages.length - 1, false); - - return () => { - model.unreadChanged?.disconnect(unreadChanged); - }; - }, [model, props.allRendered]); - - // Listen for change in the viewport, to add a navigation button if the last is not - // in viewport. - useEffect(() => { - const viewportChanged = (model: IChatModel, viewport: number[]) => { - setLastInViewport( - model.messages.length === 0 || - viewport.includes(model.messages.length - 1) - ); - }; - - model.viewportChanged?.connect(viewportChanged); - - viewportChanged(model, model.messagesInViewport ?? []); - - return () => { - model.viewportChanged?.disconnect(viewportChanged); - }; - }, [model]); - - return ( - <> - {unreadBefore !== null && ( - - )} - {(unreadAfter !== null || !lastInViewport) && ( - - )} - - ); -} - -/** - * The avatar props. - */ -type AvatarProps = { - /** - * The user to display an avatar. - */ - user: IUser; - /** - * Whether the avatar should be small. - */ - small?: boolean; -}; - -/** - * The avatar component. - */ -export function Avatar(props: AvatarProps): JSX.Element | null { - const { user } = props; - - const sharedStyles: SxProps = { - height: `${props.small ? '16' : '24'}px`, - width: `${props.small ? '16' : '24'}px`, - bgcolor: user.color, - fontSize: `var(--jp-ui-font-size${props.small ? '0' : '1'})` - }; - - const name = - user.display_name ?? user.name ?? (user.username || 'User undefined'); - return user.avatar_url ? ( - - ) : user.initials ? ( - - - {user.initials} - - - ) : null; -} diff --git a/packages/jupyter-chat/src/components/chat.tsx b/packages/jupyter-chat/src/components/chat.tsx index 513b647e..789ebb3b 100644 --- a/packages/jupyter-chat/src/components/chat.tsx +++ b/packages/jupyter-chat/src/components/chat.tsx @@ -11,11 +11,11 @@ import { IconButton } from '@mui/material'; import { Box } from '@mui/system'; import React, { useState } from 'react'; -import { JlThemeProvider } from './jl-theme-provider'; -import { IChatCommandRegistry } from '../chat-commands'; -import { ChatMessages } from './chat-messages'; import { ChatInput } from './chat-input'; import { IInputToolbarRegistry, InputToolbarRegistry } from './input'; +import { JlThemeProvider } from './jl-theme-provider'; +import { ChatMessages } from './messages'; +import { IChatCommandRegistry } from '../chat-commands'; import { AttachmentOpenerContext } from '../context'; import { IMessageFooterRegistry } from '../footers'; import { IChatModel } from '../model'; diff --git a/packages/jupyter-chat/src/components/index.ts b/packages/jupyter-chat/src/components/index.ts index d65e45fd..9a184345 100644 --- a/packages/jupyter-chat/src/components/index.ts +++ b/packages/jupyter-chat/src/components/index.ts @@ -3,12 +3,12 @@ * Distributed under the terms of the Modified BSD License. */ +export * from './avatar'; export * from './chat'; export * from './chat-input'; -export * from './chat-messages'; export * from './code-blocks'; export * from './input'; export * from './jl-theme-provider'; +export * from './messages'; export * from './mui-extras'; export * from './scroll-container'; -export * from './toolbar'; diff --git a/packages/jupyter-chat/src/components/messages/footer.tsx b/packages/jupyter-chat/src/components/messages/footer.tsx index 4ac4984d..857f5e97 100644 --- a/packages/jupyter-chat/src/components/messages/footer.tsx +++ b/packages/jupyter-chat/src/components/messages/footer.tsx @@ -5,6 +5,7 @@ import { Box } from '@mui/material'; import React from 'react'; + import { IMessageFooterRegistry, MessageFooterSectionProps @@ -24,7 +25,9 @@ export interface IMessageFootersProps extends MessageFooterSectionProps { * The chat footer component, which displays footer components on a row according to * their respective positions. */ -export function MessageFooter(props: IMessageFootersProps): JSX.Element { +export function MessageFooterComponent( + props: IMessageFootersProps +): JSX.Element { const { message, model, registry } = props; const footer = registry.getFooter(); diff --git a/packages/jupyter-chat/src/components/messages/header.tsx b/packages/jupyter-chat/src/components/messages/header.tsx new file mode 100644 index 00000000..13a1c34b --- /dev/null +++ b/packages/jupyter-chat/src/components/messages/header.tsx @@ -0,0 +1,133 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Box, Typography } from '@mui/material'; +import React, { useEffect, useState } from 'react'; + +import { Avatar } from '../avatar'; +import { IChatMessage } from '../../types'; + +const MESSAGE_HEADER_CLASS = 'jp-chat-message-header'; +const MESSAGE_TIME_CLASS = 'jp-chat-message-time'; + +/** + * The message header props. + */ +type ChatMessageHeaderProps = { + /** + * The chat message. + */ + message: IChatMessage; +}; + +/** + * The message header component. + */ +export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element { + const [datetime, setDatetime] = useState>({}); + const message = props.message; + const sender = message.sender; + /** + * Effect: update cached datetime strings upon receiving a new message. + */ + useEffect(() => { + if (!datetime[message.time]) { + const newDatetime: Record = {}; + let datetime: string; + const currentDate = new Date(); + const sameDay = (date: Date) => + date.getFullYear() === currentDate.getFullYear() && + date.getMonth() === currentDate.getMonth() && + date.getDate() === currentDate.getDate(); + + const msgDate = new Date(message.time * 1000); // Convert message time to milliseconds + + // Display only the time if the day of the message is the current one. + if (sameDay(msgDate)) { + // Use the browser's default locale + datetime = msgDate.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit' + }); + } else { + // Use the browser's default locale + datetime = msgDate.toLocaleString([], { + day: 'numeric', + month: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit' + }); + } + newDatetime[message.time] = datetime; + setDatetime(newDatetime); + } + }); + + const avatar = message.stacked ? null : Avatar({ user: sender }); + + const name = + sender.display_name ?? sender.name ?? (sender.username || 'User undefined'); + + return ( + :not(:last-child)': { + marginRight: 3 + }, + marginBottom: message.stacked ? '0px' : '12px' + }} + > + {avatar} + + + {!message.stacked && ( + + {name} + + )} + {(message.deleted || message.edited) && ( + + {message.deleted ? '(message deleted)' : '(edited)'} + + )} + + + {`${datetime[message.time]}${message.raw_time ? '*' : ''}`} + + + + ); +} diff --git a/packages/jupyter-chat/src/components/messages/index.ts b/packages/jupyter-chat/src/components/messages/index.ts new file mode 100644 index 00000000..c6a32fa9 --- /dev/null +++ b/packages/jupyter-chat/src/components/messages/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +export * from './footer'; +export * from './header'; +export * from './message'; +export * from './message-renderer'; +export * from './messages'; +export * from './navigation'; +export * from './toolbar'; +export * from './welcome'; +export * from './writers'; diff --git a/packages/jupyter-chat/src/components/messages/message-renderer.tsx b/packages/jupyter-chat/src/components/messages/message-renderer.tsx index 516cfdc1..c028001b 100644 --- a/packages/jupyter-chat/src/components/messages/message-renderer.tsx +++ b/packages/jupyter-chat/src/components/messages/message-renderer.tsx @@ -9,7 +9,7 @@ import React, { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { CodeToolbar, CodeToolbarProps } from '../code-blocks/code-toolbar'; -import { MessageToolbar } from '../toolbar'; +import { MessageToolbar } from './toolbar'; import { MarkdownRenderer, MD_RENDERED_CLASS } from '../../markdown-renderer'; import { IChatModel } from '../../model'; diff --git a/packages/jupyter-chat/src/components/messages/message.tsx b/packages/jupyter-chat/src/components/messages/message.tsx new file mode 100644 index 00000000..f89f8972 --- /dev/null +++ b/packages/jupyter-chat/src/components/messages/message.tsx @@ -0,0 +1,156 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { PromiseDelegate } from '@lumino/coreutils'; +import React, { forwardRef, useEffect, useState } from 'react'; + +import { MessageRenderer } from './message-renderer'; +import { BaseMessageProps } from './messages'; +import { AttachmentPreviewList } from '../attachments'; +import { ChatInput } from '../chat-input'; +import { IInputModel, InputModel } from '../../input-model'; +import { IChatMessage } from '../../types'; +import { replaceSpanToMention } from '../../utils'; + +/** + * The message component props. + */ +type ChatMessageProps = BaseMessageProps & { + /** + * The message to display. + */ + message: IChatMessage; + /** + * The index of the message in the list. + */ + index: number; + /** + * The promise to resolve when the message is rendered. + */ + renderedPromise: PromiseDelegate; +}; + +/** + * The message component body. + */ +export const ChatMessage = forwardRef( + (props, ref): JSX.Element => { + const { message, model, rmRegistry } = props; + const [edit, setEdit] = useState(false); + const [deleted, setDeleted] = useState(false); + const [canEdit, setCanEdit] = useState(false); + const [canDelete, setCanDelete] = useState(false); + + // Look if the message can be deleted or edited. + useEffect(() => { + // Init canDelete and canEdit state. + setDeleted(message.deleted ?? false); + if (model.user !== undefined && !message.deleted) { + if (model.user.username === message.sender.username) { + setCanEdit(model.updateMessage !== undefined); + setCanDelete(model.deleteMessage !== undefined); + return; + } + if (message.sender.bot) { + setCanDelete(model.deleteMessage !== undefined); + } + } else { + setCanEdit(false); + setCanDelete(false); + } + }, [model, message]); + + // Create an input model only if the message is edited. + const startEdition = (): void => { + if (!canEdit) { + return; + } + let body = message.body; + message.mentions?.forEach(user => { + body = replaceSpanToMention(body, user); + }); + const inputModel = new InputModel({ + chatContext: model.createChatContext(), + onSend: (input: string, model?: IInputModel) => + updateMessage(message.id, input, model), + onCancel: () => cancelEdition(), + value: body, + activeCellManager: model.activeCellManager, + selectionWatcher: model.selectionWatcher, + documentManager: model.documentManager, + config: { + sendWithShiftEnter: model.config.sendWithShiftEnter + }, + attachments: message.attachments, + mentions: message.mentions + }); + model.addEditionModel(message.id, inputModel); + setEdit(true); + }; + + // Cancel the current edition of the message. + const cancelEdition = (): void => { + model.getEditionModel(message.id)?.dispose(); + setEdit(false); + }; + + // Update the content of the message. + const updateMessage = ( + id: string, + input: string, + inputModel?: IInputModel + ): void => { + if (!canEdit || !inputModel) { + return; + } + // Update the message + const updatedMessage = { ...message }; + updatedMessage.body = input; + updatedMessage.attachments = inputModel.attachments; + updatedMessage.mentions = inputModel.mentions; + model.updateMessage!(id, updatedMessage); + model.getEditionModel(message.id)?.dispose(); + setEdit(false); + }; + + // Delete the message. + const deleteMessage = (id: string): void => { + if (!canDelete) { + return; + } + model.deleteMessage!(id); + }; + + // Empty if the message has been deleted. + return deleted ? ( +
+ ) : ( +
+ {edit && canEdit && model.getEditionModel(message.id) ? ( + cancelEdition()} + model={model.getEditionModel(message.id)!} + chatCommandRegistry={props.chatCommandRegistry} + toolbarRegistry={props.inputToolbarRegistry} + /> + ) : ( + deleteMessage(message.id) : undefined} + rendered={props.renderedPromise} + /> + )} + {message.attachments && !edit && ( + // Display the attachments only if message is not edited, otherwise the + // input component display them. + + )} +
+ ); + } +); diff --git a/packages/jupyter-chat/src/components/messages/messages.tsx b/packages/jupyter-chat/src/components/messages/messages.tsx new file mode 100644 index 00000000..4c60fee8 --- /dev/null +++ b/packages/jupyter-chat/src/components/messages/messages.tsx @@ -0,0 +1,219 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { PromiseDelegate } from '@lumino/coreutils'; +import { Box } from '@mui/material'; +import clsx from 'clsx'; +import React, { useEffect, useState, useRef } from 'react'; + +import { MessageFooterComponent } from './footer'; +import { ChatMessageHeader } from './header'; +import { ChatMessage } from './message'; +import { Navigation } from './navigation'; +import { WelcomeMessage } from './welcome'; +import { Writers } from './writers'; +import { IInputToolbarRegistry } from '../input'; +import { ScrollContainer } from '../scroll-container'; +import { IChatCommandRegistry } from '../../chat-commands'; +import { IMessageFooterRegistry } from '../../footers'; +import { IChatModel } from '../../model'; +import { IChatMessage, IUser } from '../../types'; + +const MESSAGES_BOX_CLASS = 'jp-chat-messages-container'; +const MESSAGE_CLASS = 'jp-chat-message'; +const MESSAGE_STACKED_CLASS = 'jp-chat-message-stacked'; + +/** + * The base components props. + */ +export type BaseMessageProps = { + /** + * The mime renderer registry. + */ + rmRegistry: IRenderMimeRegistry; + /** + * The chat model. + */ + model: IChatModel; + /** + * The chat commands registry. + */ + chatCommandRegistry?: IChatCommandRegistry; + /** + * The input toolbar registry. + */ + inputToolbarRegistry: IInputToolbarRegistry; + /** + * The footer registry. + */ + messageFooterRegistry?: IMessageFooterRegistry; + /** + * The welcome message. + */ + welcomeMessage?: string; +}; + +/** + * The messages list component. + */ +export function ChatMessages(props: BaseMessageProps): JSX.Element { + const { model } = props; + const [messages, setMessages] = useState(model.messages); + const refMsgBox = useRef(null); + const [currentWriters, setCurrentWriters] = useState([]); + const [allRendered, setAllRendered] = useState(false); + + // The list of message DOM and their rendered promises. + const listRef = useRef<(HTMLDivElement | null)[]>([]); + const renderedPromise = useRef[]>([]); + + /** + * Effect: fetch history and config on initial render + */ + useEffect(() => { + async function fetchHistory() { + if (!model.getHistory) { + return; + } + model + .getHistory() + .then(history => setMessages(history.messages)) + .catch(e => console.error(e)); + } + + fetchHistory(); + setCurrentWriters([]); + }, [model]); + + /** + * Effect: listen to chat messages. + */ + useEffect(() => { + function handleChatEvents() { + setMessages([...model.messages]); + } + + function handleWritersChange(_: IChatModel, writers: IChatModel.IWriter[]) { + setCurrentWriters(writers.map(writer => writer.user)); + } + + model.messagesUpdated.connect(handleChatEvents); + model.writersChanged?.connect(handleWritersChange); + + return function cleanup() { + model.messagesUpdated.disconnect(handleChatEvents); + model.writersChanged?.disconnect(handleChatEvents); + }; + }, [model]); + + /** + * Observe the messages to update the current viewport and the unread messages. + */ + useEffect(() => { + const observer = new IntersectionObserver(entries => { + // Used on first rendering, to ensure all the message as been rendered once. + if (!allRendered) { + Promise.all(renderedPromise.current.map(p => p.promise)).then(() => { + setAllRendered(true); + }); + } + + const unread = [...model.unreadMessages]; + let unreadModified = false; + const inViewport = [...(model.messagesInViewport ?? [])]; + entries.forEach(entry => { + const index = parseInt(entry.target.getAttribute('data-index') ?? ''); + if (!isNaN(index)) { + const viewportIdx = inViewport.indexOf(index); + if (!entry.isIntersecting && viewportIdx !== -1) { + inViewport.splice(viewportIdx, 1); + } else if (entry.isIntersecting && viewportIdx === -1) { + inViewport.push(index); + } + if (unread.length) { + const unreadIdx = unread.indexOf(index); + if (unreadIdx !== -1 && entry.isIntersecting) { + unread.splice(unreadIdx, 1); + unreadModified = true; + } + } + } + }); + + props.model.messagesInViewport = inViewport; + + // Ensure that all messages are rendered before updating unread messages, otherwise + // it can lead to wrong assumption , because more message are in the viewport + // before they are rendered. + if (allRendered && unreadModified) { + model.unreadMessages = unread; + } + }); + + /** + * Observe the messages. + */ + listRef.current.forEach(item => { + if (item) { + observer.observe(item); + } + }); + + return () => { + listRef.current.forEach(item => { + if (item) { + observer.unobserve(item); + } + }); + }; + }, [messages, allRendered]); + + return ( + <> + + {props.welcomeMessage && ( + + )} + + {messages.map((message, i) => { + renderedPromise.current[i] = new PromiseDelegate(); + return ( + // extra div needed to ensure each bubble is on a new line + + + (listRef.current[i] = el)} + /> + {props.messageFooterRegistry && ( + + )} + + ); + })} + + + + + + ); +} diff --git a/packages/jupyter-chat/src/components/messages/navigation.tsx b/packages/jupyter-chat/src/components/messages/navigation.tsx new file mode 100644 index 00000000..b31bae07 --- /dev/null +++ b/packages/jupyter-chat/src/components/messages/navigation.tsx @@ -0,0 +1,167 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Button } from '@jupyter/react-components'; +import { + LabIcon, + caretDownEmptyIcon, + classes +} from '@jupyterlab/ui-components'; +import React, { useEffect, useState } from 'react'; + +import { BaseMessageProps } from './messages'; +import { IChatModel } from '../../model'; + +const NAVIGATION_BUTTON_CLASS = 'jp-chat-navigation'; +const NAVIGATION_UNREAD_CLASS = 'jp-chat-navigation-unread'; +const NAVIGATION_TOP_CLASS = 'jp-chat-navigation-top'; +const NAVIGATION_BOTTOM_CLASS = 'jp-chat-navigation-bottom'; + +/** + * The navigation component props. + */ +type NavigationProps = BaseMessageProps & { + /** + * The reference to the messages container. + */ + refMsgBox: React.RefObject; + /** + * Whether all the messages has been rendered once on first display. + */ + allRendered: boolean; +}; + +/** + * The navigation component, to navigate to unread messages. + */ +export function Navigation(props: NavigationProps): JSX.Element { + const { model } = props; + const [lastInViewport, setLastInViewport] = useState(true); + const [unreadBefore, setUnreadBefore] = useState(null); + const [unreadAfter, setUnreadAfter] = useState(null); + + const gotoMessage = (msgIdx: number, alignToTop: boolean = true) => { + props.refMsgBox.current?.children.item(msgIdx)?.scrollIntoView(alignToTop); + }; + + // Listen for change in unread messages, and find the first unread message before or + // after the current viewport, to display navigation buttons. + useEffect(() => { + // Do not attempt to display navigation until messages are rendered, it can lead to + // wrong assumption, because more messages are in the viewport before they are + // rendered. + if (!props.allRendered) { + return; + } + + const unreadChanged = (model: IChatModel, unreadIndexes: number[]) => { + const viewport = model.messagesInViewport; + if (!viewport) { + return; + } + + // Initialize the next values with the current values if there still relevant. + let before = + unreadBefore !== null && + unreadIndexes.includes(unreadBefore) && + unreadBefore < Math.min(...viewport) + ? unreadBefore + : null; + + let after = + unreadAfter !== null && + unreadIndexes.includes(unreadAfter) && + unreadAfter > Math.max(...viewport) + ? unreadAfter + : null; + + unreadIndexes.forEach(unread => { + if (viewport?.includes(unread)) { + return; + } + if (unread < (before ?? Math.min(...viewport))) { + before = unread; + } else if ( + unread > Math.max(...viewport) && + unread < (after ?? model.messages.length) + ) { + after = unread; + } + }); + + setUnreadBefore(before); + setUnreadAfter(after); + }; + + model.unreadChanged?.connect(unreadChanged); + + unreadChanged(model, model.unreadMessages); + + // Move to the last the message after all the messages have been first rendered. + gotoMessage(model.messages.length - 1, false); + + return () => { + model.unreadChanged?.disconnect(unreadChanged); + }; + }, [model, props.allRendered]); + + // Listen for change in the viewport, to add a navigation button if the last is not + // in viewport. + useEffect(() => { + const viewportChanged = (model: IChatModel, viewport: number[]) => { + setLastInViewport( + model.messages.length === 0 || + viewport.includes(model.messages.length - 1) + ); + }; + + model.viewportChanged?.connect(viewportChanged); + + viewportChanged(model, model.messagesInViewport ?? []); + + return () => { + model.viewportChanged?.disconnect(viewportChanged); + }; + }, [model]); + + return ( + <> + {unreadBefore !== null && ( + + )} + {(unreadAfter !== null || !lastInViewport) && ( + + )} + + ); +} diff --git a/packages/jupyter-chat/src/components/toolbar.tsx b/packages/jupyter-chat/src/components/messages/toolbar.tsx similarity index 100% rename from packages/jupyter-chat/src/components/toolbar.tsx rename to packages/jupyter-chat/src/components/messages/toolbar.tsx diff --git a/packages/jupyter-chat/src/components/messages/welcome.tsx b/packages/jupyter-chat/src/components/messages/welcome.tsx index b55c3e17..7ad82bfe 100644 --- a/packages/jupyter-chat/src/components/messages/welcome.tsx +++ b/packages/jupyter-chat/src/components/messages/welcome.tsx @@ -5,6 +5,7 @@ import { classes } from '@jupyterlab/ui-components'; import React, { useEffect, useRef } from 'react'; + import { MarkdownRenderer, MD_RENDERED_CLASS } from '../../markdown-renderer'; const WELCOME_MESSAGE_CLASS = 'jp-chat-welcome-message'; diff --git a/packages/jupyter-chat/src/components/messages/writers.tsx b/packages/jupyter-chat/src/components/messages/writers.tsx new file mode 100644 index 00000000..1b8cc304 --- /dev/null +++ b/packages/jupyter-chat/src/components/messages/writers.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Box } from '@mui/material'; +import React from 'react'; + +import { Avatar } from '../avatar'; +import { IUser } from '../../types'; + +const WRITERS_CLASS = 'jp-chat-writers'; + +/** + * The writers component props. + */ +type writersProps = { + /** + * The list of users currently writing. + */ + writers: IUser[]; +}; + +/** + * The writers component, displaying the current writers. + */ +export function Writers(props: writersProps): JSX.Element | null { + const { writers } = props; + return writers.length > 0 ? ( + + {writers.map((writer, index) => ( +
+ + + {writer.display_name ?? + writer.name ?? + (writer.username || 'User undefined')} + + + {index < writers.length - 1 + ? index < writers.length - 2 + ? ', ' + : ' and ' + : ''} + +
+ ))} + {(writers.length > 1 ? ' are' : ' is') + ' writing'} +
+ ) : null; +} From fb2b8694dbaaf9aa996c15691d34645892cb52e7 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 6 Jun 2025 10:14:08 +0200 Subject: [PATCH 2/4] Move the chat-input module in input directory --- packages/jupyter-chat/src/components/chat.tsx | 7 +++++-- packages/jupyter-chat/src/components/index.ts | 1 - .../src/components/{ => input}/chat-input.tsx | 10 +++++----- packages/jupyter-chat/src/components/input/index.ts | 1 + .../jupyter-chat/src/components/messages/message.tsx | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) rename packages/jupyter-chat/src/components/{ => input}/chat-input.tsx (97%) diff --git a/packages/jupyter-chat/src/components/chat.tsx b/packages/jupyter-chat/src/components/chat.tsx index 789ebb3b..ddbe7ab2 100644 --- a/packages/jupyter-chat/src/components/chat.tsx +++ b/packages/jupyter-chat/src/components/chat.tsx @@ -11,8 +11,11 @@ import { IconButton } from '@mui/material'; import { Box } from '@mui/system'; import React, { useState } from 'react'; -import { ChatInput } from './chat-input'; -import { IInputToolbarRegistry, InputToolbarRegistry } from './input'; +import { + ChatInput, + IInputToolbarRegistry, + InputToolbarRegistry +} from './input'; import { JlThemeProvider } from './jl-theme-provider'; import { ChatMessages } from './messages'; import { IChatCommandRegistry } from '../chat-commands'; diff --git a/packages/jupyter-chat/src/components/index.ts b/packages/jupyter-chat/src/components/index.ts index 9a184345..d90e3a63 100644 --- a/packages/jupyter-chat/src/components/index.ts +++ b/packages/jupyter-chat/src/components/index.ts @@ -5,7 +5,6 @@ export * from './avatar'; export * from './chat'; -export * from './chat-input'; export * from './code-blocks'; export * from './input'; export * from './jl-theme-provider'; diff --git a/packages/jupyter-chat/src/components/chat-input.tsx b/packages/jupyter-chat/src/components/input/chat-input.tsx similarity index 97% rename from packages/jupyter-chat/src/components/chat-input.tsx rename to packages/jupyter-chat/src/components/input/chat-input.tsx index 330c4269..ba804739 100644 --- a/packages/jupyter-chat/src/components/chat-input.tsx +++ b/packages/jupyter-chat/src/components/input/chat-input.tsx @@ -15,15 +15,15 @@ import { import clsx from 'clsx'; import React, { useEffect, useRef, useState } from 'react'; -import { AttachmentPreviewList } from './attachments'; +import { AttachmentPreviewList } from '../attachments'; import { IInputToolbarRegistry, InputToolbarRegistry, useChatCommands -} from './input'; -import { IInputModel, InputModel } from '../input-model'; -import { IChatCommandRegistry } from '../chat-commands'; -import { IAttachment } from '../types'; +} from '.'; +import { IInputModel, InputModel } from '../../input-model'; +import { IChatCommandRegistry } from '../../chat-commands'; +import { IAttachment } from '../../types'; const INPUT_BOX_CLASS = 'jp-chat-input-container'; const INPUT_TOOLBAR_CLASS = 'jp-chat-input-toolbar'; diff --git a/packages/jupyter-chat/src/components/input/index.ts b/packages/jupyter-chat/src/components/input/index.ts index 9160199e..16673c26 100644 --- a/packages/jupyter-chat/src/components/input/index.ts +++ b/packages/jupyter-chat/src/components/input/index.ts @@ -4,5 +4,6 @@ */ export * from './buttons'; +export * from './chat-input'; export * from './toolbar-registry'; export * from './use-chat-commands'; diff --git a/packages/jupyter-chat/src/components/messages/message.tsx b/packages/jupyter-chat/src/components/messages/message.tsx index f89f8972..4d5fdd99 100644 --- a/packages/jupyter-chat/src/components/messages/message.tsx +++ b/packages/jupyter-chat/src/components/messages/message.tsx @@ -9,7 +9,7 @@ import React, { forwardRef, useEffect, useState } from 'react'; import { MessageRenderer } from './message-renderer'; import { BaseMessageProps } from './messages'; import { AttachmentPreviewList } from '../attachments'; -import { ChatInput } from '../chat-input'; +import { ChatInput } from '../input'; import { IInputModel, InputModel } from '../../input-model'; import { IChatMessage } from '../../types'; import { replaceSpanToMention } from '../../utils'; From c6418f0d5d323788da216969056f5003ec838dfb Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 6 Jun 2025 13:40:15 +0200 Subject: [PATCH 3/4] Move the registers in a dedicated directory --- .../src/chat-commands/registry.ts | 60 ------------------- packages/jupyter-chat/src/components/chat.tsx | 8 ++- .../src/components/input/chat-input.tsx | 2 +- .../components/input/use-chat-commands.tsx | 2 +- .../src/components/messages/footer.tsx | 2 +- .../src/components/messages/messages.tsx | 3 +- packages/jupyter-chat/src/context.ts | 2 +- packages/jupyter-chat/src/footers/index.ts | 7 --- packages/jupyter-chat/src/footers/types.ts | 33 ---------- packages/jupyter-chat/src/index.ts | 4 +- .../attachment-openers.ts} | 3 +- .../types.ts => registers/chat-commands.ts} | 57 ++++++++++++++++++ .../registry.ts => registers/footers.ts} | 43 ++++++++++--- .../src/{chat-commands => registers}/index.ts | 5 +- 14 files changed, 108 insertions(+), 123 deletions(-) delete mode 100644 packages/jupyter-chat/src/chat-commands/registry.ts delete mode 100644 packages/jupyter-chat/src/footers/index.ts delete mode 100644 packages/jupyter-chat/src/footers/types.ts rename packages/jupyter-chat/src/{registry.ts => registers/attachment-openers.ts} (96%) rename packages/jupyter-chat/src/{chat-commands/types.ts => registers/chat-commands.ts} (51%) rename packages/jupyter-chat/src/{footers/registry.ts => registers/footers.ts} (63%) rename packages/jupyter-chat/src/{chat-commands => registers}/index.ts (53%) diff --git a/packages/jupyter-chat/src/chat-commands/registry.ts b/packages/jupyter-chat/src/chat-commands/registry.ts deleted file mode 100644 index da41fc07..00000000 --- a/packages/jupyter-chat/src/chat-commands/registry.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) Jupyter Development Team. - * Distributed under the terms of the Modified BSD License. - */ - -import { Token } from '@lumino/coreutils'; -import { ChatCommand, IChatCommandProvider } from './types'; -import { IInputModel } from '../input-model'; - -/** - * Interface of a chat command registry, which tracks a list of chat command - * providers. Providers provide a list of commands given a user's partial input, - * and define how commands are handled when accepted in the chat commands menu. - */ -export interface IChatCommandRegistry { - addProvider(provider: IChatCommandProvider): void; - getProviders(): IChatCommandProvider[]; - - /** - * Handles a chat command by calling `handleChatCommand()` on the provider - * corresponding to this chat command. - */ - handleChatCommand(command: ChatCommand, inputModel: IInputModel): void; -} - -/** - * Default chat command registry implementation. - */ -export class ChatCommandRegistry implements IChatCommandRegistry { - constructor() { - this._providers = new Map(); - } - - addProvider(provider: IChatCommandProvider): void { - this._providers.set(provider.id, provider); - } - - getProviders(): IChatCommandProvider[] { - return Array.from(this._providers.values()); - } - - handleChatCommand(command: ChatCommand, inputModel: IInputModel) { - const provider = this._providers.get(command.providerId); - if (!provider) { - console.error( - 'Error in handling chat command: No command provider has an ID of ' + - command.providerId - ); - return; - } - - provider.handleChatCommand(command, inputModel); - } - - private _providers: Map; -} - -export const IChatCommandRegistry = new Token( - '@jupyter/chat:IChatCommandRegistry' -); diff --git a/packages/jupyter-chat/src/components/chat.tsx b/packages/jupyter-chat/src/components/chat.tsx index ddbe7ab2..fb6abc32 100644 --- a/packages/jupyter-chat/src/components/chat.tsx +++ b/packages/jupyter-chat/src/components/chat.tsx @@ -18,11 +18,13 @@ import { } from './input'; import { JlThemeProvider } from './jl-theme-provider'; import { ChatMessages } from './messages'; -import { IChatCommandRegistry } from '../chat-commands'; import { AttachmentOpenerContext } from '../context'; -import { IMessageFooterRegistry } from '../footers'; import { IChatModel } from '../model'; -import { IAttachmentOpenerRegistry } from '../registry'; +import { + IAttachmentOpenerRegistry, + IChatCommandRegistry, + IMessageFooterRegistry +} from '../registers'; export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { const { model } = props; diff --git a/packages/jupyter-chat/src/components/input/chat-input.tsx b/packages/jupyter-chat/src/components/input/chat-input.tsx index ba804739..2cfc1e7d 100644 --- a/packages/jupyter-chat/src/components/input/chat-input.tsx +++ b/packages/jupyter-chat/src/components/input/chat-input.tsx @@ -22,7 +22,7 @@ import { useChatCommands } from '.'; import { IInputModel, InputModel } from '../../input-model'; -import { IChatCommandRegistry } from '../../chat-commands'; +import { IChatCommandRegistry } from '../../registers'; import { IAttachment } from '../../types'; const INPUT_BOX_CLASS = 'jp-chat-input-container'; diff --git a/packages/jupyter-chat/src/components/input/use-chat-commands.tsx b/packages/jupyter-chat/src/components/input/use-chat-commands.tsx index 324221ed..c3cfb2b4 100644 --- a/packages/jupyter-chat/src/components/input/use-chat-commands.tsx +++ b/packages/jupyter-chat/src/components/input/use-chat-commands.tsx @@ -11,7 +11,7 @@ import type { import { Box } from '@mui/material'; import React, { useEffect, useState } from 'react'; -import { ChatCommand, IChatCommandRegistry } from '../../chat-commands'; +import { ChatCommand, IChatCommandRegistry } from '../../registers'; import { IInputModel } from '../../input-model'; type AutocompleteProps = GenericAutocompleteProps; diff --git a/packages/jupyter-chat/src/components/messages/footer.tsx b/packages/jupyter-chat/src/components/messages/footer.tsx index 857f5e97..036ccbbc 100644 --- a/packages/jupyter-chat/src/components/messages/footer.tsx +++ b/packages/jupyter-chat/src/components/messages/footer.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { IMessageFooterRegistry, MessageFooterSectionProps -} from '../../footers'; +} from '../../registers'; /** * The chat footer component properties. diff --git a/packages/jupyter-chat/src/components/messages/messages.tsx b/packages/jupyter-chat/src/components/messages/messages.tsx index 4c60fee8..958f8147 100644 --- a/packages/jupyter-chat/src/components/messages/messages.tsx +++ b/packages/jupyter-chat/src/components/messages/messages.tsx @@ -17,8 +17,7 @@ import { WelcomeMessage } from './welcome'; import { Writers } from './writers'; import { IInputToolbarRegistry } from '../input'; import { ScrollContainer } from '../scroll-container'; -import { IChatCommandRegistry } from '../../chat-commands'; -import { IMessageFooterRegistry } from '../../footers'; +import { IChatCommandRegistry, IMessageFooterRegistry } from '../../registers'; import { IChatModel } from '../../model'; import { IChatMessage, IUser } from '../../types'; diff --git a/packages/jupyter-chat/src/context.ts b/packages/jupyter-chat/src/context.ts index 81925e85..75f4166f 100644 --- a/packages/jupyter-chat/src/context.ts +++ b/packages/jupyter-chat/src/context.ts @@ -3,7 +3,7 @@ * Distributed under the terms of the Modified BSD License. */ import { createContext } from 'react'; -import { IAttachmentOpenerRegistry } from './registry'; +import { IAttachmentOpenerRegistry } from './registers'; export const AttachmentOpenerContext = createContext< IAttachmentOpenerRegistry | undefined diff --git a/packages/jupyter-chat/src/footers/index.ts b/packages/jupyter-chat/src/footers/index.ts deleted file mode 100644 index 68a63353..00000000 --- a/packages/jupyter-chat/src/footers/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright (c) Jupyter Development Team. - * Distributed under the terms of the Modified BSD License. - */ - -export * from './registry'; -export * from './types'; diff --git a/packages/jupyter-chat/src/footers/types.ts b/packages/jupyter-chat/src/footers/types.ts deleted file mode 100644 index a5e37f87..00000000 --- a/packages/jupyter-chat/src/footers/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) Jupyter Development Team. - * Distributed under the terms of the Modified BSD License. - */ - -import { IChatModel } from '../model'; -import { IChatMessage } from '../types'; - -/** - * The props sent passed to each `MessageFooterSection` React component. - */ -export type MessageFooterSectionProps = { - model: IChatModel; - message: IChatMessage; -}; - -/** - * A message footer section which can be added to the footer registry. - */ -export type MessageFooterSection = { - component: React.FC; - position: 'left' | 'center' | 'right'; -}; - -/** - * The message footer returned by the registry, composed of 'left', 'center', - * and 'right' sections. - */ -export type MessageFooter = { - left?: MessageFooterSection; - center?: MessageFooterSection; - right?: MessageFooterSection; -}; diff --git a/packages/jupyter-chat/src/index.ts b/packages/jupyter-chat/src/index.ts index f87e4a2b..fc60c5b5 100644 --- a/packages/jupyter-chat/src/index.ts +++ b/packages/jupyter-chat/src/index.ts @@ -4,14 +4,12 @@ */ export * from './active-cell-manager'; -export * from './chat-commands'; export * from './components'; -export * from './footers'; export * from './icons'; export * from './input-model'; export * from './markdown-renderer'; export * from './model'; -export * from './registry'; +export * from './registers'; export * from './selection-watcher'; export * from './types'; export * from './widgets/chat-error'; diff --git a/packages/jupyter-chat/src/registry.ts b/packages/jupyter-chat/src/registers/attachment-openers.ts similarity index 96% rename from packages/jupyter-chat/src/registry.ts rename to packages/jupyter-chat/src/registers/attachment-openers.ts index 7d4661c0..1a59bfd4 100644 --- a/packages/jupyter-chat/src/registry.ts +++ b/packages/jupyter-chat/src/registers/attachment-openers.ts @@ -2,8 +2,9 @@ * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ + import { Token } from '@lumino/coreutils'; -import { IAttachment } from './types'; +import { IAttachment } from '../types'; /** * The token for the attachments opener registry, which can be provided by an extension diff --git a/packages/jupyter-chat/src/chat-commands/types.ts b/packages/jupyter-chat/src/registers/chat-commands.ts similarity index 51% rename from packages/jupyter-chat/src/chat-commands/types.ts rename to packages/jupyter-chat/src/registers/chat-commands.ts index ad14de08..8c485d5e 100644 --- a/packages/jupyter-chat/src/chat-commands/types.ts +++ b/packages/jupyter-chat/src/registers/chat-commands.ts @@ -4,8 +4,33 @@ */ import { LabIcon } from '@jupyterlab/ui-components'; +import { Token } from '@lumino/coreutils'; import { IInputModel } from '../input-model'; +/** + * The token for the chat command registry, which can be provided by an extension + * using @jupyter/chat package. + */ +export const IChatCommandRegistry = new Token( + '@jupyter/chat:IChatCommandRegistry' +); + +/** + * Interface of a chat command registry, which tracks a list of chat command + * providers. Providers provide a list of commands given a user's partial input, + * and define how commands are handled when accepted in the chat commands menu. + */ +export interface IChatCommandRegistry { + addProvider(provider: IChatCommandProvider): void; + getProviders(): IChatCommandProvider[]; + + /** + * Handles a chat command by calling `handleChatCommand()` on the provider + * corresponding to this chat command. + */ + handleChatCommand(command: ChatCommand, inputModel: IInputModel): void; +} + export type ChatCommand = { /** * The name of the command. This defines what the user should type in the @@ -65,3 +90,35 @@ export interface IChatCommandProvider { inputModel: IInputModel ): Promise; } + +/** + * Default chat command registry implementation. + */ +export class ChatCommandRegistry implements IChatCommandRegistry { + constructor() { + this._providers = new Map(); + } + + addProvider(provider: IChatCommandProvider): void { + this._providers.set(provider.id, provider); + } + + getProviders(): IChatCommandProvider[] { + return Array.from(this._providers.values()); + } + + handleChatCommand(command: ChatCommand, inputModel: IInputModel) { + const provider = this._providers.get(command.providerId); + if (!provider) { + console.error( + 'Error in handling chat command: No command provider has an ID of ' + + command.providerId + ); + return; + } + + provider.handleChatCommand(command, inputModel); + } + + private _providers: Map; +} diff --git a/packages/jupyter-chat/src/footers/registry.ts b/packages/jupyter-chat/src/registers/footers.ts similarity index 63% rename from packages/jupyter-chat/src/footers/registry.ts rename to packages/jupyter-chat/src/registers/footers.ts index f8d3b351..12dcf888 100644 --- a/packages/jupyter-chat/src/footers/registry.ts +++ b/packages/jupyter-chat/src/registers/footers.ts @@ -4,7 +4,15 @@ */ import { Token } from '@lumino/coreutils'; -import { MessageFooter, MessageFooterSection } from './types'; +import { IChatModel } from '../model'; +import { IChatMessage } from '../types'; + +/** + * The token providing the chat footer registry. + */ +export const IMessageFooterRegistry = new Token( + '@jupyter/chat:ChatFooterRegistry' +); /** * The interface of a registry to provide chat footer. @@ -22,6 +30,32 @@ export interface IMessageFooterRegistry { addSection(section: MessageFooterSection): void; } +/** + * The props sent passed to each `MessageFooterSection` React component. + */ +export type MessageFooterSectionProps = { + model: IChatModel; + message: IChatMessage; +}; + +/** + * A message footer section which can be added to the footer registry. + */ +export type MessageFooterSection = { + component: React.FC; + position: 'left' | 'center' | 'right'; +}; + +/** + * The message footer returned by the registry, composed of 'left', 'center', + * and 'right' sections. + */ +export type MessageFooter = { + left?: MessageFooterSection; + center?: MessageFooterSection; + right?: MessageFooterSection; +}; + /** * The default implementation of the message footer registry. */ @@ -43,10 +77,3 @@ export class MessageFooterRegistry implements IMessageFooterRegistry { private _footers: MessageFooter = {}; } - -/** - * The token providing the chat footer registry. - */ -export const IMessageFooterRegistry = new Token( - '@jupyter/chat:ChatFooterRegistry' -); diff --git a/packages/jupyter-chat/src/chat-commands/index.ts b/packages/jupyter-chat/src/registers/index.ts similarity index 53% rename from packages/jupyter-chat/src/chat-commands/index.ts rename to packages/jupyter-chat/src/registers/index.ts index ff8700e3..988f5de3 100644 --- a/packages/jupyter-chat/src/chat-commands/index.ts +++ b/packages/jupyter-chat/src/registers/index.ts @@ -3,5 +3,6 @@ * Distributed under the terms of the Modified BSD License. */ -export * from './types'; -export * from './registry'; +export * from './attachment-openers'; +export * from './chat-commands'; +export * from './footers'; From 1db09c1f5e5e35f0a391001a97f9cfeb110bb7e7 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 6 Jun 2025 13:42:41 +0200 Subject: [PATCH 4/4] Add an index file to the widgets --- packages/jupyter-chat/src/index.ts | 4 +--- packages/jupyter-chat/src/widgets/index.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 packages/jupyter-chat/src/widgets/index.ts diff --git a/packages/jupyter-chat/src/index.ts b/packages/jupyter-chat/src/index.ts index fc60c5b5..ad7b8f26 100644 --- a/packages/jupyter-chat/src/index.ts +++ b/packages/jupyter-chat/src/index.ts @@ -12,6 +12,4 @@ export * from './model'; export * from './registers'; export * from './selection-watcher'; export * from './types'; -export * from './widgets/chat-error'; -export * from './widgets/chat-sidebar'; -export * from './widgets/chat-widget'; +export * from './widgets'; diff --git a/packages/jupyter-chat/src/widgets/index.ts b/packages/jupyter-chat/src/widgets/index.ts new file mode 100644 index 00000000..d2696485 --- /dev/null +++ b/packages/jupyter-chat/src/widgets/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +export * from './chat-error'; +export * from './chat-sidebar'; +export * from './chat-widget';