diff --git a/workbench-app/.vscode/launch.json b/workbench-app/.vscode/launch.json index 6e467952..ccf05cdf 100644 --- a/workbench-app/.vscode/launch.json +++ b/workbench-app/.vscode/launch.json @@ -10,6 +10,19 @@ "console": "integratedTerminal", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"] + }, + { + "type": "node", + "request": "launch", + "name": "app: semantic-workbench-app (no strict mode)", + "env": { + "VITE_DISABLE_STRICT_MODE": "true" + }, + "cwd": "${workspaceFolder}", + "skipFiles": ["/**"], + "console": "integratedTerminal", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"] } ] } diff --git a/workbench-app/src/Constants.ts b/workbench-app/src/Constants.ts index ea429e36..fafacae7 100644 --- a/workbench-app/src/Constants.ts +++ b/workbench-app/src/Constants.ts @@ -12,7 +12,7 @@ export const Constants = { maxFileAttachmentsPerMessage: 10, loaderDelayMs: 100, responsiveBreakpoints: { - interactCanvas: '900px', + chatCanvas: '900px', }, speechIdleTimeoutMs: 4000, azureSpeechTokenRefreshIntervalMs: 540000, // 540000 ms = 9 minutes diff --git a/workbench-app/src/components/App/AppView.tsx b/workbench-app/src/components/App/AppView.tsx index b35b2be7..c137cb9d 100644 --- a/workbench-app/src/components/App/AppView.tsx +++ b/workbench-app/src/components/App/AppView.tsx @@ -49,7 +49,7 @@ interface AppViewProps { export const AppView: React.FC = (props) => { const { title, actions, fullSizeContent, children } = props; const classes = useClasses(); - const { completedFirstRun } = useAppSelector((state) => state.app); + const completedFirstRun = useAppSelector((state) => state.app.completedFirstRun); const navigate = useNavigate(); React.useLayoutEffect(() => { diff --git a/workbench-app/src/components/App/DialogControl.tsx b/workbench-app/src/components/App/DialogControl.tsx index 6237a6cf..cb35b4b3 100644 --- a/workbench-app/src/components/App/DialogControl.tsx +++ b/workbench-app/src/components/App/DialogControl.tsx @@ -57,11 +57,7 @@ export const DialogControl: React.FC = (props) => { )} - {additionalActions?.map((action, index) => ( - - {action} - - ))} + {additionalActions} diff --git a/workbench-app/src/components/App/ErrorListFromAppState.tsx b/workbench-app/src/components/App/ErrorListFromAppState.tsx index 5f57f6f1..d44cda94 100644 --- a/workbench-app/src/components/App/ErrorListFromAppState.tsx +++ b/workbench-app/src/components/App/ErrorListFromAppState.tsx @@ -26,7 +26,7 @@ interface ErrorListFromAppStateProps { export const ErrorListFromAppState: React.FC = (props) => { const { className } = props; const classes = useClasses(); - const { errors } = useAppSelector((state: RootState) => state.app); + const errors = useAppSelector((state: RootState) => state.app.errors); const dispatch = useAppDispatch(); if (!errors || errors.length === 0) { diff --git a/workbench-app/src/components/App/ExperimentalNotice.tsx b/workbench-app/src/components/App/ExperimentalNotice.tsx index e211c3fc..4d5481a3 100644 --- a/workbench-app/src/components/App/ExperimentalNotice.tsx +++ b/workbench-app/src/components/App/ExperimentalNotice.tsx @@ -59,7 +59,7 @@ const useClasses = makeStyles({ export const ExperimentalNotice: React.FC = () => { const classes = useClasses(); - const { completedFirstRun } = useAppSelector((state) => state.app); + const completedFirstRun = useAppSelector((state) => state.app.completedFirstRun); const dispatch = useAppDispatch(); const [showDialog, setShowDialog] = React.useState(!completedFirstRun?.experimental); const [currentIndex, setCurrentIndex] = React.useState(0); diff --git a/workbench-app/src/components/App/ProfileSettings.tsx b/workbench-app/src/components/App/ProfileSettings.tsx index a85152c5..2afa62f7 100644 --- a/workbench-app/src/components/App/ProfileSettings.tsx +++ b/workbench-app/src/components/App/ProfileSettings.tsx @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -import { useIsAuthenticated, useMsal } from '@azure/msal-react'; +import { useAccount, useIsAuthenticated, useMsal } from '@azure/msal-react'; import { Label, Link, @@ -15,7 +15,7 @@ import React from 'react'; import { AuthHelper } from '../../libs/AuthHelper'; import { useMicrosoftGraph } from '../../libs/useMicrosoftGraph'; import { useAppDispatch, useAppSelector } from '../../redux/app/hooks'; -import { setUserPhoto } from '../../redux/features/app/appSlice'; +import { setLocalUser } from '../../redux/features/localUser/localUserSlice'; const useClasses = makeStyles({ popoverSurface: { @@ -33,31 +33,32 @@ export const ProfileSettings: React.FC = () => { const classes = useClasses(); const { instance } = useMsal(); const isAuthenticated = useIsAuthenticated(); + const account = useAccount(); const microsoftGraph = useMicrosoftGraph(); - const { localUser, userPhoto } = useAppSelector((state) => state.app); + const localUserState = useAppSelector((state) => state.localUser); const dispatch = useAppDispatch(); - // FIXME: prevent multiple calls before the setUserPhoto is updated - // If not wrapped in a useEffect, an error is thrown when the state is updated - // while other components are still rendering. Putting in a useEffect - // prevents the error from being thrown, but then the photo may get fetched - // multiple times when multiple components are rendering at the same time - // and the state update has not yet been processed. Not the end of the world, - // as it tends to be just a few calls, but it's not ideal. React.useEffect(() => { - if (isAuthenticated && !userPhoto.src && !userPhoto.isLoading) { - dispatch(setUserPhoto({ isLoading: true, src: undefined })); - (async () => { - const photo = await microsoftGraph.getMyPhotoAsync(); - dispatch( - setUserPhoto({ - isLoading: false, - src: photo, - }), - ); - })(); - } - }, [dispatch, isAuthenticated, microsoftGraph, userPhoto.isLoading, userPhoto.src]); + (async () => { + if (!isAuthenticated || !account?.name || localUserState.id) { + return; + } + + const photo = await microsoftGraph.getMyPhotoAsync(); + + dispatch( + setLocalUser({ + id: (account.homeAccountId || '').split('.').reverse().join('.'), + name: account.name, + email: account.username, + avatar: { + name: account.name, + image: photo ? { src: photo } : undefined, + }, + }), + ); + })(); + }, [account, dispatch, isAuthenticated, localUserState.id, microsoftGraph]); const handleSignOut = () => { void AuthHelper.logoutAsync(instance); @@ -70,15 +71,15 @@ export const ProfileSettings: React.FC = () => { return ( - +
- {isAuthenticated && localUser ? ( + {isAuthenticated ? ( <>
- - + +
Sign Out diff --git a/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx b/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx index 229a623b..74b9764d 100644 --- a/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx +++ b/workbench-app/src/components/Conversations/Canvas/AssistantCanvasList.tsx @@ -2,7 +2,7 @@ import { Tab, TabList, makeStyles, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; -import { useInteractCanvasController } from '../../../libs/useInteractCanvasController'; +import { useChatCanvasController } from '../../../libs/useChatCanvasController'; import { Assistant } from '../../../models/Assistant'; import { Conversation } from '../../../models/Conversation'; import { AssistantCanvas } from './AssistantCanvas'; @@ -39,7 +39,7 @@ interface AssistantCanvasListProps { export const AssistantCanvasList: React.FC = (props) => { const { selectedAssistant, conversationAssistants, conversation } = props; const classes = useClasses(); - const interactCanvasController = useInteractCanvasController(); + const chatCanvasController = useChatCanvasController(); if (conversationAssistants.length === 1) { // Only one assistant, no need to show tabs, just show the single assistant @@ -61,10 +61,10 @@ export const AssistantCanvasList: React.FC = (props) = ); // Set the new assistant as the active assistant - // If we can't find the assistant, we'll set the assistant to null - interactCanvasController.transitionToState({ - assistantId: conversationAssistant?.id ?? null, - assistantStateId: null, + // If we can't find the assistant, we'll set the assistant to undefined + chatCanvasController.transitionToState({ + selectedAssistantId: conversationAssistant?.id, + selectedAssistantStateId: undefined, }); }} size="small" diff --git a/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx b/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx index 51e44640..f92de0ab 100644 --- a/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx +++ b/workbench-app/src/components/Conversations/Canvas/AssistantInspectorList.tsx @@ -11,7 +11,7 @@ import { tokens, } from '@fluentui/react-components'; import React from 'react'; -import { useInteractCanvasController } from '../../../libs/useInteractCanvasController'; +import { useChatCanvasController } from '../../../libs/useChatCanvasController'; import { Assistant } from '../../../models/Assistant'; import { AssistantStateDescription } from '../../../models/AssistantStateDescription'; import { useAppSelector } from '../../../redux/app/hooks'; @@ -54,8 +54,8 @@ interface AssistantInspectorListProps { export const AssistantInspectorList: React.FC = (props) => { const { conversationId, assistant, stateDescriptions } = props; const classes = useClasses(); - const { interactCanvasState } = useAppSelector((state) => state.app); - const interactCanvasController = useInteractCanvasController(); + const chatCanvasState = useAppSelector((state) => state.chatCanvas); + const chatCanvasController = useChatCanvasController(); if (stateDescriptions.length === 1) { // Only one assistant state, no need to show tabs, just show the single assistant state @@ -69,7 +69,7 @@ export const AssistantInspectorList: React.FC = (pr } const onTabSelect: SelectTabEventHandler = (_event: SelectTabEvent, data: SelectTabData) => { - interactCanvasController.transitionToState({ assistantStateId: data.value as string }); + chatCanvasController.transitionToState({ selectedAssistantStateId: data.value as string }); }; if (stateDescriptions.length === 0) { @@ -85,8 +85,9 @@ export const AssistantInspectorList: React.FC = (pr } const selectedStateDescription = - stateDescriptions.find((stateDescription) => stateDescription.id === interactCanvasState?.assistantStateId) ?? - stateDescriptions[0]; + stateDescriptions.find( + (stateDescription) => stateDescription.id === chatCanvasState.selectedAssistantStateId, + ) ?? stateDescriptions[0]; const selectedTab = selectedStateDescription.id; return ( diff --git a/workbench-app/src/components/Conversations/Canvas/CanvasControls.tsx b/workbench-app/src/components/Conversations/Canvas/CanvasControls.tsx index 98fe53c5..8ac6247e 100644 --- a/workbench-app/src/components/Conversations/Canvas/CanvasControls.tsx +++ b/workbench-app/src/components/Conversations/Canvas/CanvasControls.tsx @@ -4,8 +4,8 @@ import { Button, makeStyles, shorthands, tokens, Tooltip } from '@fluentui/react import { AppsList24Regular, BookInformation24Regular, Dismiss24Regular } from '@fluentui/react-icons'; import { EventSourceMessage } from '@microsoft/fetch-event-source'; import React from 'react'; +import { useChatCanvasController } from '../../../libs/useChatCanvasController'; import { useEnvironment } from '../../../libs/useEnvironment'; -import { useInteractCanvasController } from '../../../libs/useInteractCanvasController'; import { WorkbenchEventSource, WorkbenchEventSourceType } from '../../../libs/WorkbenchEventSource'; import { useAppSelector } from '../../../redux/app/hooks'; @@ -31,20 +31,20 @@ interface CanvasControlsProps { export const CanvasControls: React.FC = (props) => { const { conversationId } = props; const classes = useClasses(); - const { interactCanvasState } = useAppSelector((state) => state.app); + const chatCanvasState = useAppSelector((state) => state.chatCanvas); const environment = useEnvironment(); - const interactCanvasController = useInteractCanvasController(); + const chatCanvasController = useChatCanvasController(); React.useEffect(() => { var workbenchEventSource: WorkbenchEventSource | undefined; const handleFocusEvent = async (event: EventSourceMessage) => { const { data } = JSON.parse(event.data); - interactCanvasController.transitionToState({ + chatCanvasController.transitionToState({ open: true, mode: 'assistant', - assistantId: data['assistant_id'], - assistantStateId: data['state_id'], + selectedAssistantId: data['assistant_id'], + selectedAssistantStateId: data['state_id'], }); }; @@ -60,35 +60,35 @@ export const CanvasControls: React.FC = (props) => { return () => { workbenchEventSource?.removeEventListener('assistant.state.focus', handleFocusEvent); }; - }, [environment, conversationId, interactCanvasController]); + }, [environment, conversationId, chatCanvasController]); const handleActivateConversation = () => { - interactCanvasController.transitionToState({ open: true, mode: 'conversation' }); + chatCanvasController.transitionToState({ open: true, mode: 'conversation' }); }; const handleActivateAssistant = () => { - interactCanvasController.transitionToState({ open: true, mode: 'assistant' }); + chatCanvasController.transitionToState({ open: true, mode: 'assistant' }); }; const handleDismiss = async () => { - interactCanvasController.transitionToState({ open: false }); + chatCanvasController.transitionToState({ open: false }); }; - const conversationActive = interactCanvasState?.mode === 'conversation' && interactCanvasState?.open; - const assistantActive = interactCanvasState?.mode === 'assistant' && interactCanvasState?.open; + const conversationActive = chatCanvasState.mode === 'conversation' && chatCanvasState.open; + const assistantActive = chatCanvasState.mode === 'assistant' && chatCanvasState.open; return (
-
-
- ); -}; diff --git a/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx b/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx index 62dc05b0..1c6c99d2 100644 --- a/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx @@ -1,34 +1,93 @@ -import { DialogTrigger } from '@fluentui/react-components'; +import { Button, DialogTrigger } from '@fluentui/react-components'; import { ChatAddRegular } from '@fluentui/react-icons'; import React from 'react'; -import { CommandButton } from '../../App/CommandButton'; -import { NewConversation } from './NewConversation'; +import { useConversationUtility } from '../../../libs/useConversationUtility'; +import { useCreateConversation } from '../../../libs/useCreateConversation'; +import { DialogControl } from '../../App/DialogControl'; +import { ConversationsImport } from '../../Conversations/ConversationsImport'; +import { NewConversationForm } from './NewConversationForm'; export const NewConversationButton: React.FC = () => { const [open, setOpen] = React.useState(false); + const { createConversation } = useCreateConversation(); + const [isValid, setIsValid] = React.useState(false); + const [title, setTitle] = React.useState(); + const [assistantId, setAssistantId] = React.useState(); + const [name, setName] = React.useState(); + const [assistantServiceId, setAssistantServiceId] = React.useState(); + const [submitted, setSubmitted] = React.useState(false); + const { navigateToConversation } = useConversationUtility(); + + const handleCreate = React.useCallback(async () => { + if (submitted || !isValid || !title || !assistantId) { + return; + } + + // ensure we have a valid assistant info + let assistantInfo: { assistantId: string } | { name: string; assistantServiceId: string } | undefined; + if (assistantId === 'new' && name && assistantServiceId) { + assistantInfo = { name, assistantServiceId }; + } else { + assistantInfo = { assistantId }; + } + + if (!assistantInfo) { + return; + } + setSubmitted(true); + + try { + const { conversation } = await createConversation(title, assistantInfo); + navigateToConversation(conversation.id); + } finally { + // In case of error, allow user to retry + setSubmitted(false); + } + + setOpen(false); + }, [assistantId, assistantServiceId, createConversation, isValid, name, navigateToConversation, submitted, title]); + + const handleImport = React.useCallback( + (conversationIds: string[]) => { + if (conversationIds.length > 0) { + navigateToConversation(conversationIds[0]); + } + setOpen(false); + }, + [navigateToConversation], + ); return ( - } - iconOnly - onClick={() => setOpen(true)} - dialogContent={{ - open, - title: 'New Conversation with Assistant', - content: ( - setOpen(false)} - onImport={() => setOpen(false)} - dismissButton={ - - - - } - /> - ), - hideDismissButton: true, - }} + setOpen(!open)} + trigger={ + , + , + ]} /> ); }; diff --git a/workbench-app/src/components/FrontDoor/Controls/NewConversationForm.tsx b/workbench-app/src/components/FrontDoor/Controls/NewConversationForm.tsx index 33510dd7..f9f8c0ce 100644 --- a/workbench-app/src/components/FrontDoor/Controls/NewConversationForm.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/NewConversationForm.tsx @@ -43,34 +43,43 @@ export const NewConversationForm: React.FC = (props) = const classes = useClasses(); const { assistants, assistantServicesByCategories } = useCreateConversation(); - const [title, setTitle] = React.useState(''); - const [assistantId, setAssistantId] = React.useState(); - const [name, setName] = React.useState(''); - const [assistantServiceId, setAssistantServiceId] = React.useState(''); + const [config, setConfig] = React.useState({ + title: '', + assistantId: '', + assistantServiceId: '', + name: '', + }); const [manualEntry, setManualEntry] = React.useState(false); - const isValid = React.useMemo(() => { - if (!title || !assistantId) { + const checkIsValid = React.useCallback((data: NewConversationData) => { + if (!data.title || !data.assistantId) { return false; } - if (assistantId === 'new') { - if (!assistantServiceId || !name) { + if (data.assistantId === 'new') { + if (!data.assistantServiceId || !data.name) { return false; } } return true; - }, [title, assistantId, assistantServiceId, name]); + }, []); - const notifyChange = React.useCallback(() => { - onChange?.(isValid, { - title: title === '' ? undefined : title, - assistantId, - assistantServiceId: assistantServiceId === '' ? undefined : assistantServiceId, - name: name === '' ? undefined : name, - }); - }, [onChange, isValid, title, assistantId, assistantServiceId, name]); + const isValid = React.useMemo(() => checkIsValid(config), [checkIsValid, config]); + + const updateAndNotifyChange = React.useCallback( + (data: NewConversationData) => { + const updatedConfig = { ...config, ...data }; + if (data.assistantId === 'new') { + updatedConfig.assistantServiceId = data.assistantServiceId ?? ''; + updatedConfig.name = data.name ?? ''; + } + + setConfig(updatedConfig); + onChange?.(checkIsValid(updatedConfig), updatedConfig); + }, + [checkIsValid, config, onChange], + ); return (
= (props) = { - setTitle(data?.value); - notifyChange(); - }} + value={config.title} + onChange={(_event, data) => updateAndNotifyChange({ title: data?.value })} aria-autocomplete="none" /> { - setAssistantId(assistantId); - if (assistantId === 'new') { - setAssistantServiceId(''); - setName(''); - notifyChange(); - } - }} + onChange={(assistantId) => + updateAndNotifyChange({ + assistantId, + assistantServiceId: assistantId === 'new' ? '' : undefined, + name: assistantId === 'new' ? '' : undefined, + }) + } disabled={disabled} /> - {assistantId === 'new' && ( + {config.assistantId === 'new' && ( <> {!manualEntry && ( { - setAssistantServiceId(assistantService.assistantServiceId); - setName(assistantService.name); - notifyChange(); - }} + onChange={(assistantService) => updateAndNotifyChange(assistantService)} /> )} @@ -126,11 +127,10 @@ export const NewConversationForm: React.FC = (props) = { - setAssistantServiceId(data?.value); - notifyChange(); - }} + value={config.assistantServiceId} + onChange={(_event, data) => + updateAndNotifyChange({ assistantServiceId: data?.value }) + } aria-autocomplete="none" /> @@ -138,11 +138,8 @@ export const NewConversationForm: React.FC = (props) = { - setName(data?.value); - notifyChange(); - }} + value={config.name} + onChange={(_event, data) => updateAndNotifyChange({ name: data?.value })} aria-autocomplete="none" /> @@ -154,7 +151,6 @@ export const NewConversationForm: React.FC = (props) = checked={manualEntry} onChange={(_event, data) => { setManualEntry(data.checked === true); - notifyChange(); }} /> diff --git a/workbench-app/src/components/FrontDoor/Controls/SiteMenuButton.tsx b/workbench-app/src/components/FrontDoor/Controls/SiteMenuButton.tsx index ea808991..49dbf0c2 100644 --- a/workbench-app/src/components/FrontDoor/Controls/SiteMenuButton.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/SiteMenuButton.tsx @@ -26,7 +26,7 @@ import { useNavigate } from 'react-router-dom'; import { AuthHelper } from '../../../libs/AuthHelper'; import { useMicrosoftGraph } from '../../../libs/useMicrosoftGraph'; import { useAppDispatch, useAppSelector } from '../../../redux/app/hooks'; -import { setLocalUser, setUserPhoto } from '../../../redux/features/app/appSlice'; +import { setLocalUser } from '../../../redux/features/localUser/localUserSlice'; const useClasses = makeStyles({ accountInfo: { @@ -43,48 +43,30 @@ export const SiteMenuButton: React.FC = () => { const isAuthenticated = useIsAuthenticated(); const account = useAccount(); const microsoftGraph = useMicrosoftGraph(); - const { localUser, userPhoto } = useAppSelector((state) => state.app); + const localUserState = useAppSelector((state) => state.localUser); const dispatch = useAppDispatch(); - // FIXME: prevent multiple calls before the setUserPhoto is updated - // If not wrapped in a useEffect, an error is thrown when the state is updated - // while other components are still rendering. Putting in a useEffect - // prevents the error from being thrown, but then the photo may get fetched - // multiple times when multiple components are rendering at the same time - // and the state update has not yet been processed. Not the end of the world, - // as it tends to be just a few calls, but it's not ideal. React.useEffect(() => { - if (isAuthenticated && !userPhoto.src && !userPhoto.isLoading) { - dispatch(setUserPhoto({ isLoading: true, src: undefined })); - (async () => { - const photo = await microsoftGraph.getMyPhotoAsync(); - dispatch( - setUserPhoto({ - isLoading: false, - src: photo, - }), - ); - })(); - } - }, [dispatch, isAuthenticated, microsoftGraph, userPhoto.isLoading, userPhoto.src]); + (async () => { + if (!isAuthenticated || !account?.name || localUserState.id) { + return; + } - React.useEffect(() => { - if (!isAuthenticated || localUser || !account?.name) { - return; - } + const photo = await microsoftGraph.getMyPhotoAsync(); - dispatch( - setLocalUser({ - id: (account.homeAccountId || '').split('.').reverse().join('.'), - name: account.name, - email: account.username, - avatar: { + dispatch( + setLocalUser({ + id: (account.homeAccountId || '').split('.').reverse().join('.'), name: account.name, - image: userPhoto.src ? { src: userPhoto.src } : undefined, - }, - }), - ); - }, [account, dispatch, isAuthenticated, localUser, userPhoto.src]); + email: account.username, + avatar: { + name: account.name, + image: photo ? { src: photo } : undefined, + }, + }), + ); + })(); + }, [account, dispatch, isAuthenticated, localUserState.id, microsoftGraph]); const handleSignOut = () => { void AuthHelper.logoutAsync(instance); @@ -97,15 +79,15 @@ export const SiteMenuButton: React.FC = () => { return ( - + {isAuthenticated && ( <>
- - + +
diff --git a/workbench-app/src/components/FrontDoor/MainContent.tsx b/workbench-app/src/components/FrontDoor/MainContent.tsx index e8b4bce5..8556af17 100644 --- a/workbench-app/src/components/FrontDoor/MainContent.tsx +++ b/workbench-app/src/components/FrontDoor/MainContent.tsx @@ -1,10 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. -import { makeStyles, shorthands, Title3, tokens } from '@fluentui/react-components'; +import { Button, makeStyles, shorthands, Title3, tokens } from '@fluentui/react-components'; import React from 'react'; +import { useConversationUtility } from '../../libs/useConversationUtility'; +import { useCreateConversation } from '../../libs/useCreateConversation'; import { useAppSelector } from '../../redux/app/hooks'; +import { ConversationsImport } from '../Conversations/ConversationsImport'; import { Chat } from './Chat/Chat'; -import { NewConversation } from './Controls/NewConversation'; +import { NewConversationForm } from './Controls/NewConversationForm'; const useClasses = makeStyles({ root: { @@ -33,6 +36,13 @@ const useClasses = makeStyles({ maxWidth: '550px', ...shorthands.padding(tokens.spacingVerticalM, tokens.spacingHorizontalM), }, + actions: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + }, }); interface MainContentProps { @@ -42,10 +52,54 @@ interface MainContentProps { export const MainContent: React.FC = (props) => { const { headerBefore, headerAfter } = props; - const { activeConversationId } = useAppSelector((state) => state.app); + const activeConversationId = useAppSelector((state) => state.app.activeConversationId); + const { createConversation } = useCreateConversation(); + const [isValid, setIsValid] = React.useState(false); + const [title, setTitle] = React.useState(); + const [assistantId, setAssistantId] = React.useState(); + const [name, setName] = React.useState(); + const [assistantServiceId, setAssistantServiceId] = React.useState(); + const [submitted, setSubmitted] = React.useState(false); + const { navigateToConversation } = useConversationUtility(); const classes = useClasses(); + const handleCreate = React.useCallback(async () => { + if (submitted || !isValid || !title || !assistantId) { + return; + } + + // ensure we have a valid assistant info + let assistantInfo: { assistantId: string } | { name: string; assistantServiceId: string } | undefined; + if (assistantId === 'new' && name && assistantServiceId) { + assistantInfo = { name, assistantServiceId }; + } else { + assistantInfo = { assistantId }; + } + + if (!assistantInfo) { + return; + } + setSubmitted(true); + + try { + const { conversation } = await createConversation(title, assistantInfo); + navigateToConversation(conversation.id); + } finally { + // In case of error, allow user to retry + setSubmitted(false); + } + }, [assistantId, assistantServiceId, createConversation, isValid, name, navigateToConversation, submitted, title]); + + const handleImport = React.useCallback( + (conversationIds: string[]) => { + if (conversationIds.length > 0) { + navigateToConversation(conversationIds[0]); + } + }, + [navigateToConversation], + ); + if (activeConversationId) { return ; } @@ -63,7 +117,27 @@ export const MainContent: React.FC = (props) => {
Create a new conversation with an assistant - + { + setIsValid(isValid); + setTitle(data.title); + setAssistantId(data.assistantId); + setAssistantServiceId(data.assistantServiceId); + setName(data.name); + }} + disabled={submitted} + /> +
+ + +
diff --git a/workbench-app/src/components/Workflows/WorkflowConversation.tsx b/workbench-app/src/components/Workflows/WorkflowConversation.tsx index 1515ffed..09e3e653 100644 --- a/workbench-app/src/components/Workflows/WorkflowConversation.tsx +++ b/workbench-app/src/components/Workflows/WorkflowConversation.tsx @@ -18,8 +18,8 @@ import { InteractHistory } from '../../components/Conversations/InteractHistory' import { InteractInput } from '../../components/Conversations/InteractInput'; import { WorkbenchEventSource, WorkbenchEventSourceType } from '../../libs/WorkbenchEventSource'; import { useGetAssistantCapabilities } from '../../libs/useAssistantCapabilities'; +import { useChatCanvasController } from '../../libs/useChatCanvasController'; import { useEnvironment } from '../../libs/useEnvironment'; -import { useInteractCanvasController } from '../../libs/useInteractCanvasController'; import { useSiteUtility } from '../../libs/useSiteUtility'; import { WorkflowDefinition } from '../../models/WorkflowDefinition'; import { WorkflowRun } from '../../models/WorkflowRun'; @@ -131,9 +131,10 @@ export const WorkflowConversation: React.FC = (props) const { conversationId, workflowRun } = props; const classes = useClasses(); - const { chatWidthPercent, interactCanvasState } = useAppSelector((state) => state.app); + const chatWidthPercent = useAppSelector((state) => state.app.chatWidthPercent); + const chatCanvasState = useAppSelector((state) => state.chatCanvas); const dispatch = useAppDispatch(); - const interactCanvasController = useInteractCanvasController(); + const chatCanvasController = useChatCanvasController(); const animationFrame = React.useRef(0); const resizeHandleRef = React.useRef(null); @@ -220,11 +221,11 @@ export const WorkflowConversation: React.FC = (props) const handleFocusEvent = (event: EventSourceMessage) => { const { data } = JSON.parse(event.data); - interactCanvasController.transitionToState({ + chatCanvasController.transitionToState({ open: true, mode: 'assistant', - assistantId: data['assistant_id'], - assistantStateId: data['state_id'], + selectedAssistantId: data['assistant_id'], + selectedAssistantStateId: data['state_id'], }); }; @@ -240,7 +241,7 @@ export const WorkflowConversation: React.FC = (props) return () => { workbenchEventSource?.removeEventListener('assistant.state.focus', handleFocusEvent); }; - }, [environment, conversationId, dispatch, interactCanvasController]); + }, [environment, conversationId, dispatch, chatCanvasController]); if ( isLoadingWorkflowRunAssistants || @@ -261,7 +262,7 @@ export const WorkflowConversation: React.FC = (props)
= (props)
= (props) } />
- {!interactCanvasState?.open && ( + {!chatCanvasState.open && (
)} @@ -313,7 +314,7 @@ export const WorkflowConversation: React.FC = (props) ref={resizeHandleRef} onMouseDown={startResizing} /> - {interactCanvasState?.open && ( + {chatCanvasState.open && ( { const classes = useClasses(); - const { completedFirstRun } = useAppSelector((state) => state.app); + const completedFirstRun = useAppSelector((state) => state.app.completedFirstRun); const dispatch = useAppDispatch(); const [showHelp, setShowHelp] = React.useState(!completedFirstRun?.workflow); const [currentIndex, setCurrentIndex] = React.useState(0); diff --git a/workbench-app/src/libs/useInteractCanvasController.ts b/workbench-app/src/libs/useChatCanvasController.ts similarity index 70% rename from workbench-app/src/libs/useInteractCanvasController.ts rename to workbench-app/src/libs/useChatCanvasController.ts index 1a545304..0b2bfa1b 100644 --- a/workbench-app/src/libs/useInteractCanvasController.ts +++ b/workbench-app/src/libs/useChatCanvasController.ts @@ -1,14 +1,14 @@ import debug from 'debug'; import React from 'react'; import { Constants } from '../Constants'; -import { InteractCanvasState } from '../models/InteractCanvasState'; import { useAppDispatch, useAppSelector } from '../redux/app/hooks'; -import { setInteractCanvasState } from '../redux/features/app/appSlice'; +import { setChatCanvasOpen, setChatCanvasState } from '../redux/features/chatCanvas/chatCanvasSlice'; +import { ChatCanvasState } from '../redux/features/chatCanvas/ChatCanvasState'; const log = debug(Constants.debug.root).extend('useCanvasController'); -export const useInteractCanvasController = () => { - const { interactCanvasState } = useAppSelector((state) => state.app); +export const useChatCanvasController = () => { + const chatCanvasState = useAppSelector((state) => state.chatCanvas); const [isTransitioning, setIsTransitioning] = React.useState(false); const dispatch = useAppDispatch(); @@ -16,7 +16,7 @@ export const useInteractCanvasController = () => { const openingTransitionDelayMs = 200; const chooseTransitionType = React.useCallback( - (currentCanvasState: InteractCanvasState, fullTargetCanvasState: InteractCanvasState) => { + (currentCanvasState: ChatCanvasState, fullTargetCanvasState: ChatCanvasState) => { if (!currentCanvasState.open && fullTargetCanvasState.open) { return 'open'; } @@ -35,7 +35,7 @@ export const useInteractCanvasController = () => { log(`canvas closing with transition of ${closingTransitionDelayMs}ms`); // close the canvas - dispatch(setInteractCanvasState({ open: false })); + dispatch(setChatCanvasOpen(false)); // wait for the canvas to close before indicating that we are no longer transitioning await new Promise((resolve) => setTimeout(resolve, closingTransitionDelayMs)); @@ -43,11 +43,11 @@ export const useInteractCanvasController = () => { }, [dispatch]); const transitionCloseToOpen = React.useCallback( - async (fullTargetCanvasState: InteractCanvasState) => { + async (fullTargetCanvasState: ChatCanvasState) => { log(`canvas opening with transition of ${openingTransitionDelayMs}ms`); // open the canvas with the new mode - dispatch(setInteractCanvasState(fullTargetCanvasState)); + dispatch(setChatCanvasState(fullTargetCanvasState)); // wait for the canvas to open before indicating that we are no longer transitioning await new Promise((resolve) => setTimeout(resolve, openingTransitionDelayMs)); @@ -56,7 +56,7 @@ export const useInteractCanvasController = () => { if (fullTargetCanvasState.mode === 'assistant') { log( - `assistant state: ${fullTargetCanvasState.assistantStateId} [assistant: ${fullTargetCanvasState.assistantId}]`, + `assistant state: ${fullTargetCanvasState.selectedAssistantStateId} [assistant: ${fullTargetCanvasState.selectedAssistantId}]`, ); } }, @@ -64,7 +64,7 @@ export const useInteractCanvasController = () => { ); const transitionOpenToNewMode = React.useCallback( - async (fullTargetCanvasState: InteractCanvasState) => { + async (fullTargetCanvasState: ChatCanvasState) => { log('canvas changing mode while open'); await transitionOpenToClose(); await transitionCloseToOpen(fullTargetCanvasState); @@ -73,22 +73,14 @@ export const useInteractCanvasController = () => { ); const setState = React.useCallback( - (targetCanvasState: Partial) => { - dispatch(setInteractCanvasState({ ...interactCanvasState, ...targetCanvasState })); + (targetCanvasState: Partial) => { + dispatch(setChatCanvasState({ ...chatCanvasState, ...targetCanvasState })); }, - [dispatch, interactCanvasState], + [dispatch, chatCanvasState], ); const transitionToState = React.useCallback( - async (targetCanvasState: Partial) => { - if (!interactCanvasState) { - // this should not happen, but just in case we have no state, set it and return - dispatch(setInteractCanvasState({ ...targetCanvasState })); - // ensure that we are not claiming to be transitioning - setIsTransitioning(false); - return; - } - + async (targetCanvasState: Partial) => { // // we should always set the isTransitioning state to true before we start any transitions // so that we can disable various UX elements that should not be interacted with @@ -118,8 +110,8 @@ export const useInteractCanvasController = () => { setIsTransitioning(true); // determine the type of transition that we need to perform - const transitionType = chooseTransitionType(interactCanvasState, { - ...interactCanvasState, + const transitionType = chooseTransitionType(chatCanvasState, { + ...chatCanvasState, ...targetCanvasState, }); @@ -127,17 +119,17 @@ export const useInteractCanvasController = () => { switch (transitionType) { case 'open': await transitionOpenToClose(); - await transitionCloseToOpen({ ...interactCanvasState, ...targetCanvasState }); + await transitionCloseToOpen({ ...chatCanvasState, ...targetCanvasState }); break; case 'close': await transitionOpenToClose(); break; case 'mode': - await transitionOpenToNewMode({ ...interactCanvasState, ...targetCanvasState }); + await transitionOpenToNewMode({ ...chatCanvasState, ...targetCanvasState }); break; case 'none': // no transition needed, just update the state - dispatch(setInteractCanvasState({ ...interactCanvasState, ...targetCanvasState })); + dispatch(setChatCanvasState({ ...chatCanvasState, ...targetCanvasState })); setIsTransitioning(false); break; } @@ -148,12 +140,12 @@ export const useInteractCanvasController = () => { [ chooseTransitionType, dispatch, - interactCanvasState, + chatCanvasState, transitionCloseToOpen, transitionOpenToClose, transitionOpenToNewMode, ], ); - return { interactCanvasState, isTransitioning, setState, transitionToState }; + return { chatCanvasState, isTransitioning, setState, transitionToState }; }; diff --git a/workbench-app/src/libs/useConversationUtility.ts b/workbench-app/src/libs/useConversationUtility.ts index 2c46b4ba..d56a69e4 100644 --- a/workbench-app/src/libs/useConversationUtility.ts +++ b/workbench-app/src/libs/useConversationUtility.ts @@ -1,12 +1,15 @@ +import debug from 'debug'; import React from 'react'; import { useNavigate } from 'react-router-dom'; import { Constants } from '../Constants'; import { Conversation } from '../models/Conversation'; import { ConversationShare } from '../models/ConversationShare'; +import { useAppSelector } from '../redux/app/hooks'; import { useUpdateConversationMutation } from '../services/workbench'; -import { useLocalUser } from './useLocalUser'; import { Utility } from './Utility'; +const log = debug(Constants.debug.root).extend('useConversationUtility'); + // Share types to be used in the app. export const enum ConversationShareType { NotRedeemable = 'Not redeemable', @@ -24,7 +27,7 @@ export const useConversationUtility = () => { const [isMessageVisible, setIsVisible] = React.useState(false); const isMessageVisibleRef = React.useRef(null); const [updateConversation] = useUpdateConversationMutation(); - const localUser = useLocalUser(); + const localUserId = useAppSelector((state) => state.localUser.id); const navigate = useNavigate(); // region Navigation @@ -55,9 +58,9 @@ export const useConversationUtility = () => { const wasSharedWithMe = React.useCallback( (conversation: Conversation): boolean => { - return conversation.ownerId !== localUser.id; + return conversation.ownerId !== localUserId; }, - [localUser.id], + [localUserId], ); const getShareTypeMetadata = React.useCallback( @@ -119,9 +122,18 @@ export const useConversationUtility = () => { const setAppMetadata = React.useCallback( async (conversation: Conversation, appMetadata: Partial) => { + if (!localUserId) { + log( + 'Local user ID not set while setting conversation metadata for user, skipping', + `[Conversation ID: ${conversation.id}]`, + appMetadata, + ); + return; + } + const participantAppMetadata: Record = conversation.metadata?.participantAppMetadata ?? {}; - const userAppMetadata = participantAppMetadata[localUser.id] ?? {}; + const userAppMetadata = participantAppMetadata[localUserId] ?? {}; // Save the conversation await updateConversation({ @@ -130,12 +142,12 @@ export const useConversationUtility = () => { ...conversation.metadata, participantAppMetadata: { ...participantAppMetadata, - [localUser.id]: { ...userAppMetadata, ...appMetadata }, + [localUserId]: { ...userAppMetadata, ...appMetadata }, }, }, }); }, - [updateConversation, localUser.id], + [updateConversation, localUserId], ); // endregion @@ -164,11 +176,14 @@ export const useConversationUtility = () => { const getLastReadTimestamp = React.useCallback( (conversation: Conversation) => { + if (!localUserId) { + return; + } const participantAppMetadata: Record = conversation.metadata?.participantAppMetadata ?? {}; - return participantAppMetadata[localUser.id]?.lastReadTimestamp; + return participantAppMetadata[localUserId]?.lastReadTimestamp; }, - [localUser.id], + [localUserId], ); const getLastMessageTimestamp = React.useCallback((conversation: Conversation) => { @@ -191,7 +206,7 @@ export const useConversationUtility = () => { (conversation: Conversation, messageTimestamp: string) => { const lastReadTimestamp = getLastReadTimestamp(conversation); if (!lastReadTimestamp) { - return true; + return false; } return messageTimestamp > lastReadTimestamp; }, @@ -258,11 +273,12 @@ export const useConversationUtility = () => { const isPinned = React.useCallback( (conversation: Conversation) => { + if (!localUserId) return; const participantAppMetadata: Record = conversation.metadata?.participantAppMetadata ?? {}; - return participantAppMetadata[localUser.id]?.pinned; + return participantAppMetadata[localUserId]?.pinned; }, - [localUser.id], + [localUserId], ); const setPinned = React.useCallback( diff --git a/workbench-app/src/libs/useCreateConversation.ts b/workbench-app/src/libs/useCreateConversation.ts index 2dcb295d..9208ac1a 100644 --- a/workbench-app/src/libs/useCreateConversation.ts +++ b/workbench-app/src/libs/useCreateConversation.ts @@ -2,6 +2,7 @@ import React from 'react'; import { Constants } from '../Constants'; import { Assistant } from '../models/Assistant'; import { AssistantServiceRegistration } from '../models/AssistantServiceRegistration'; +import { useAppSelector } from '../redux/app/hooks'; import { useAddConversationParticipantMutation, useCreateAssistantMutation, @@ -10,7 +11,6 @@ import { useGetAssistantServiceRegistrationsQuery, useGetAssistantsQuery, } from '../services/workbench'; -import { useLocalUser } from './useLocalUser'; export const useCreateConversation = () => { const { @@ -36,7 +36,7 @@ export const useCreateConversation = () => { const [createConversationMessage] = useCreateConversationMessageMutation(); const [isFetching, setIsFetching] = React.useState(false); - const localUser = useLocalUser(); + const localUserName = useAppSelector((state) => state.localUser.name); if (assistantsError) { const errorMessage = JSON.stringify(assistantsError); @@ -101,7 +101,7 @@ export const useCreateConversation = () => { // send event to notify the conversation that the user has joined await createConversationMessage({ conversationId: conversation.id, - content: `${localUser.name} created the conversation`, + content: `${localUserName ?? 'Unknown user'} created the conversation`, messageType: 'notice', }); @@ -128,7 +128,7 @@ export const useCreateConversation = () => { myAssistantServicesLoading, createConversation, createConversationMessage, - localUser.name, + localUserName, addConversationParticipant, assistants, createAssistant, diff --git a/workbench-app/src/libs/useEnvironment.ts b/workbench-app/src/libs/useEnvironment.ts index 9ed09455..e53110fb 100644 --- a/workbench-app/src/libs/useEnvironment.ts +++ b/workbench-app/src/libs/useEnvironment.ts @@ -12,7 +12,7 @@ import debug from 'debug'; const log = debug(Constants.debug.root).extend('useEnvironment'); export const useEnvironment = () => { - const { environmentId } = useAppSelector((state: RootState) => state.settings); + const environmentId = useAppSelector((state: RootState) => state.settings.environmentId); const [environment, setEnvironment] = React.useState(getEnvironment(environmentId)); React.useEffect(() => { diff --git a/workbench-app/src/libs/useLocalUser.ts b/workbench-app/src/libs/useLocalUser.ts deleted file mode 100644 index 873e2919..00000000 --- a/workbench-app/src/libs/useLocalUser.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. -import { useAccount } from '@azure/msal-react'; -import React from 'react'; - -export const useLocalUser = () => { - const account = useAccount(); - // FIXME: re-enable all of this - // const { userPhoto } = useAppSelector((state) => state.app); - // const isAuthenticated = useIsAuthenticated(); - // const dispatch = useAppDispatch(); - - // FIXME: prevent multiple calls before the setUserPhoto is updated - // If not wrapped in a useEffect, an error is thrown when the state is updated - // while other components are still rendering. Putting in a useEffect - // prevents the error from being thrown, but then the photo may get fetched - // multiple times when multiple components are rendering at the same time - // and the state update has not yet been processed. Not the end of the world, - // as it tends to be just a few calls, but it's not ideal. - // React.useEffect(() => { - // if (isAuthenticated && !userPhoto.src && !userPhoto.isLoading) { - // dispatch(setUserPhoto({ isLoading: true, src: undefined })); - // (async () => { - // const photo = await microsoftGraph.getMyPhotoAsync(); - // dispatch( - // setUserPhoto({ - // isLoading: false, - // src: photo, - // }), - // ); - // })(); - // } - // }, [dispatch, isAuthenticated, microsoftGraph, userPhoto.isLoading, userPhoto.src]); - - const getUserId = React.useCallback(() => { - // AAD accountID is ., while the participantId is . - const userId = (account?.homeAccountId || '').split('.').reverse().join('.'); - - if (!userId) { - throw new Error('User ID is not available.'); - } - - return userId; - }, [account]); - - // const localUser = React.useMemo( - // () => ({ - // id: getUserId(), - // name: account?.name, - // email: account?.username, - // avatar: { - // name: account?.name, - // image: userPhoto.src ? { src: userPhoto.src } : undefined, - // }, - // }), - // [account?.name, account?.username, getUserId, userPhoto.src], - // ); - - const localUser = React.useMemo( - () => ({ - id: getUserId(), - name: account?.name, - email: account?.username, - avatar: { - name: account?.name, - image: undefined, - }, - }), - [account?.name, account?.username, getUserId], - ); - - return localUser; -}; diff --git a/workbench-app/src/libs/useParticipantUtility.tsx b/workbench-app/src/libs/useParticipantUtility.tsx index e510a29d..245475e4 100644 --- a/workbench-app/src/libs/useParticipantUtility.tsx +++ b/workbench-app/src/libs/useParticipantUtility.tsx @@ -2,21 +2,21 @@ import { AvatarProps } from '@fluentui/react-components'; import { AppGenericRegular, BotRegular, PersonRegular } from '@fluentui/react-icons'; import React from 'react'; import { ConversationParticipant } from '../models/ConversationParticipant'; -import { useLocalUser } from './useLocalUser'; +import { useAppSelector } from '../redux/app/hooks'; export const useParticipantUtility = () => { - const localUser = useLocalUser(); + const localUserState = useAppSelector((state) => state.localUser); const getAvatarData = React.useCallback( (participant: ConversationParticipant | 'localUser') => { if (participant === 'localUser') { - return localUser.avatar; + return localUserState.avatar; } const { id, name, image, role } = participant; - if (id === localUser.id) { - return localUser.avatar; + if (id === localUserState.id) { + return localUserState.avatar; } let avatar: AvatarProps = { @@ -35,7 +35,7 @@ export const useParticipantUtility = () => { return avatar; }, - [localUser.avatar, localUser.id], + [localUserState.avatar, localUserState.id], ); const sortParticipants = React.useCallback( diff --git a/workbench-app/src/main.tsx b/workbench-app/src/main.tsx index f2918731..380bc6e3 100644 --- a/workbench-app/src/main.tsx +++ b/workbench-app/src/main.tsx @@ -148,24 +148,40 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error('Could not find root element'); } const root = ReactDOM.createRoot(container); - log('starting app'); - root.render( - - - - - - - - - - - - - - - - , + + const app = ( + + + + + + + + + + + + + + ); + + // NOTE: React.StrictMode is used to help catch common issues in the app but will also double-render + // components.If you want to verify that any double rendering is coming from this, you can disable + // React.StrictMode by setting the env var VITE_DISABLE_STRICT_MODE = true. Please note that this + // will also disable the double-render check, so only use this for debugging purposes and make sure + // to test with React.StrictMode enabled before committing any changes. + + // Can be overridden by env var VITE_DISABLE_STRICT_MODE + const disableStrictMode = import.meta.env.VITE_DISABLE_STRICT_MODE === 'true'; + + let startLogMessage = 'starting app'; + if (import.meta.env.DEV) { + startLogMessage = `${startLogMessage} in development mode`; + startLogMessage = `${startLogMessage} [strict mode: ${disableStrictMode ? 'disabled' : 'enabled'}]`; + } + + log(startLogMessage); + root.render(disableStrictMode ? app : {app}); } }); diff --git a/workbench-app/src/models/InteractCanvasState.ts b/workbench-app/src/models/InteractCanvasState.ts deleted file mode 100644 index 30fdf517..00000000 --- a/workbench-app/src/models/InteractCanvasState.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface InteractCanvasState { - open?: boolean; - mode?: 'conversation' | 'assistant'; - assistantId?: string | null; - assistantStateId?: string | null; -} diff --git a/workbench-app/src/redux/app/store.ts b/workbench-app/src/redux/app/store.ts index 9e189a01..a5994e0f 100644 --- a/workbench-app/src/redux/app/store.ts +++ b/workbench-app/src/redux/app/store.ts @@ -1,12 +1,16 @@ import { Action, ThunkAction, configureStore } from '@reduxjs/toolkit'; import { workbenchApi } from '../../services/workbench'; import appReducer from '../features/app/appSlice'; +import chatCanvasReducer from '../features/chatCanvas/chatCanvasSlice'; +import localUserReducer from '../features/localUser/localUserSlice'; import settingsReducer from '../features/settings/settingsSlice'; import { rtkQueryErrorLogger } from './rtkQueryErrorLogger'; export const store = configureStore({ reducer: { app: appReducer, + chatCanvas: chatCanvasReducer, + localUser: localUserReducer, settings: settingsReducer, [workbenchApi.reducerPath]: workbenchApi.reducer, }, diff --git a/workbench-app/src/redux/features/app/AppState.ts b/workbench-app/src/redux/features/app/AppState.ts index fa8f50c8..0be147a6 100644 --- a/workbench-app/src/redux/features/app/AppState.ts +++ b/workbench-app/src/redux/features/app/AppState.ts @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -import { InteractCanvasState } from '../../../models/InteractCanvasState'; - export interface AppState { devMode: boolean; errors: { @@ -15,22 +13,6 @@ export interface AppState { workflow: boolean; }; chatWidthPercent: number; - interactCanvasState: InteractCanvasState; - localUser?: { - id: string; - name: string; - email: string; - avatar: { - name: string; - image?: { - src: string; - }; - }; - }; - userPhoto: { - src?: string; - isLoading: boolean; - }; isDraggingOverBody?: boolean; activeConversationId?: string; } diff --git a/workbench-app/src/redux/features/app/appSlice.ts b/workbench-app/src/redux/features/app/appSlice.ts index 0d58fac2..66bf6c5a 100644 --- a/workbench-app/src/redux/features/app/appSlice.ts +++ b/workbench-app/src/redux/features/app/appSlice.ts @@ -4,7 +4,6 @@ import { generateUuid } from '@azure/ms-rest-js'; import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { Constants } from '../../../Constants'; import { AppStorage } from '../../../libs/AppStorage'; -import { InteractCanvasState } from '../../../models/InteractCanvasState'; import { conversationApi } from '../../../services/workbench'; import { AppState } from './AppState'; @@ -28,14 +27,6 @@ const initialState: AppState = { AppStorage.getInstance().loadObject(localStorageKey.completedFirstRunExperimental) ?? false, workflow: AppStorage.getInstance().loadObject(localStorageKey.completedFirstRunWorkflow) ?? false, }, - interactCanvasState: { - open: false, - mode: 'conversation', - }, - userPhoto: { - src: undefined, - isLoading: false, - }, }; export const appSlice = createSlice({ @@ -96,24 +87,6 @@ export const appSlice = createSlice({ state.completedFirstRun.workflow = action.payload.workflow; } }, - setInteractCanvasState: (state: AppState, action: PayloadAction>) => { - // update only the provided properties - if (action.payload.open !== undefined) { - state.interactCanvasState.open = action.payload.open; - } - - if (action.payload.mode !== undefined) { - state.interactCanvasState.mode = action.payload.mode; - } - - if (action.payload.assistantId !== undefined) { - state.interactCanvasState.assistantId = action.payload.assistantId; - } - - if (action.payload.assistantStateId !== undefined) { - state.interactCanvasState.assistantStateId = action.payload.assistantStateId; - } - }, setActiveConversationId: (state: AppState, action: PayloadAction) => { if (action.payload === state.activeConversationId) { return; @@ -125,21 +98,6 @@ export const appSlice = createSlice({ conversationApi.endpoints.getConversationMessages.initiate(action.payload, { forceRefetch: true }); } }, - setLocalUser: (state: AppState, action: PayloadAction) => { - state.localUser = action.payload; - }, - setUserPhoto: (state: AppState, action: PayloadAction<{ src?: string; isLoading?: boolean }>) => { - state.userPhoto.src = action.payload.src; - state.userPhoto.isLoading = action.payload.isLoading ?? false; - - // update local user avatar - if (state.localUser) { - state.localUser.avatar = { - ...state.localUser.avatar, - image: action.payload.src ? { src: action.payload.src } : undefined, - }; - } - }, }, }); @@ -151,10 +109,7 @@ export const { clearErrors, setChatWidthPercent, setCompletedFirstRun, - setInteractCanvasState, setActiveConversationId, - setLocalUser, - setUserPhoto, } = appSlice.actions; export default appSlice.reducer; diff --git a/workbench-app/src/redux/features/chatCanvas/ChatCanvasState.ts b/workbench-app/src/redux/features/chatCanvas/ChatCanvasState.ts new file mode 100644 index 00000000..f35c9d19 --- /dev/null +++ b/workbench-app/src/redux/features/chatCanvas/ChatCanvasState.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +export interface ChatCanvasState { + open: boolean; + mode: 'conversation' | 'assistant'; + selectedAssistantId?: string; + selectedAssistantStateId?: string; +} diff --git a/workbench-app/src/redux/features/chatCanvas/chatCanvasSlice.ts b/workbench-app/src/redux/features/chatCanvas/chatCanvasSlice.ts new file mode 100644 index 00000000..70188934 --- /dev/null +++ b/workbench-app/src/redux/features/chatCanvas/chatCanvasSlice.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { ChatCanvasState } from './ChatCanvasState'; + +const initialState: ChatCanvasState = { + open: false, + mode: 'conversation', +}; + +export const chatCanvasSlice = createSlice({ + name: 'chatCanvas', + initialState, + reducers: { + setChatCanvasOpen: (state: ChatCanvasState, action: PayloadAction) => { + state.open = action.payload; + }, + setChatCanvasMode: (state: ChatCanvasState, action: PayloadAction) => { + state.mode = action.payload; + }, + setChatCanvasAssistantId: (state: ChatCanvasState, action: PayloadAction) => { + state.selectedAssistantId = action.payload; + }, + setChatCanvasAssistantStateId: (state: ChatCanvasState, action: PayloadAction) => { + state.selectedAssistantStateId = action.payload; + }, + setChatCanvasState: (state: ChatCanvasState, action: PayloadAction) => { + Object.assign(state, action.payload); + }, + }, +}); + +export const { + setChatCanvasOpen, + setChatCanvasMode, + setChatCanvasAssistantId, + setChatCanvasAssistantStateId, + setChatCanvasState, +} = chatCanvasSlice.actions; + +export default chatCanvasSlice.reducer; diff --git a/workbench-app/src/redux/features/localUser/LocalUserState.ts b/workbench-app/src/redux/features/localUser/LocalUserState.ts new file mode 100644 index 00000000..378f75d8 --- /dev/null +++ b/workbench-app/src/redux/features/localUser/LocalUserState.ts @@ -0,0 +1,11 @@ +export interface LocalUserState { + id?: string; + name?: string; + email?: string; + avatar: { + name?: string; + image?: { + src: string; + }; + }; +} diff --git a/workbench-app/src/redux/features/localUser/localUserSlice.ts b/workbench-app/src/redux/features/localUser/localUserSlice.ts new file mode 100644 index 00000000..cd0fb7bd --- /dev/null +++ b/workbench-app/src/redux/features/localUser/localUserSlice.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { LocalUserState } from './LocalUserState'; + +const initialState: LocalUserState = { + avatar: {}, +}; + +export const localUserSlice = createSlice({ + name: 'localUser', + initialState, + reducers: { + setLocalUser: (state: LocalUserState, action: PayloadAction) => { + Object.assign(state, action.payload); + }, + }, +}); + +export const { setLocalUser } = localUserSlice.actions; + +export default localUserSlice.reducer; diff --git a/workbench-app/src/routes/AcceptTerms.tsx b/workbench-app/src/routes/AcceptTerms.tsx index 0036e17f..198736d7 100644 --- a/workbench-app/src/routes/AcceptTerms.tsx +++ b/workbench-app/src/routes/AcceptTerms.tsx @@ -49,7 +49,7 @@ const useClasses = makeStyles({ export const AcceptTerms: React.FC = () => { const classes = useClasses(); - const { completedFirstRun } = useAppSelector((state) => state.app); + const completedFirstRun = useAppSelector((state) => state.app.completedFirstRun); const dispatch = useAppDispatch(); const navigate = useNavigate(); const location = useLocation(); diff --git a/workbench-app/src/routes/AssistantEditor.tsx b/workbench-app/src/routes/AssistantEditor.tsx index 48c615fc..6a0e7a7e 100644 --- a/workbench-app/src/routes/AssistantEditor.tsx +++ b/workbench-app/src/routes/AssistantEditor.tsx @@ -12,10 +12,10 @@ import { AssistantExport } from '../components/Assistants/AssistantExport'; import { AssistantRename } from '../components/Assistants/AssistantRename'; import { AssistantServiceMetadata } from '../components/Assistants/AssistantServiceMetadata'; import { MyConversations } from '../components/Conversations/MyConversations'; -import { useLocalUser } from '../libs/useLocalUser'; import { useSiteUtility } from '../libs/useSiteUtility'; import { Assistant } from '../models/Assistant'; import { Conversation } from '../models/Conversation'; +import { useAppSelector } from '../redux/app/hooks'; import { useAddConversationParticipantMutation, useCreateConversationMessageMutation, @@ -68,7 +68,7 @@ export const AssistantEditor: React.FC = () => { const [updateAssistant] = useUpdateAssistantMutation(); const [addConversationParticipant] = useAddConversationParticipantMutation(); const [createConversationMessage] = useCreateConversationMessageMutation(); - const localUser = useLocalUser(); + const localUserName = useAppSelector((state) => state.localUser.name); const siteUtility = useSiteUtility(); const navigate = useNavigate(); @@ -113,7 +113,7 @@ export const AssistantEditor: React.FC = () => { // send event to notify the conversation that the user has joined await createConversationMessage({ conversationId: conversation.id, - content: `${localUser.name} created the conversation`, + content: `${localUserName ?? 'Unknown user'} created the conversation`, messageType: 'notice', }); diff --git a/workbench-app/src/routes/Dashboard.tsx b/workbench-app/src/routes/Dashboard.tsx index f44de70b..d5a93212 100644 --- a/workbench-app/src/routes/Dashboard.tsx +++ b/workbench-app/src/routes/Dashboard.tsx @@ -11,9 +11,9 @@ import { MyAssistants } from '../components/Assistants/MyAssistants'; import { MyConversations } from '../components/Conversations/MyConversations'; import { MyWorkflows } from '../components/Workflows/MyWorkflows'; import { Constants } from '../Constants'; -import { useLocalUser } from '../libs/useLocalUser'; import { useSiteUtility } from '../libs/useSiteUtility'; import { Conversation } from '../models/Conversation'; +import { useAppSelector } from '../redux/app/hooks'; import { useGetAssistantsQuery, useGetConversationsQuery } from '../services/workbench'; import { useGetWorkflowDefinitionsQuery } from '../services/workbench/workflow'; @@ -43,7 +43,7 @@ export const Dashboard: React.FC = () => { error: workflowDefinitionsError, isLoading: isLoadingWorkflowDefinitions, } = useGetWorkflowDefinitionsQuery(); - const localUser = useLocalUser(); + const localUserStateId = useAppSelector((state) => state.localUser.id); const navigate = useNavigate(); const siteUtility = useSiteUtility(); @@ -81,9 +81,9 @@ export const Dashboard: React.FC = () => { ); } - const myConversations = conversations?.filter((conversation) => conversation.ownerId === localUser.id) || []; + const myConversations = conversations?.filter((conversation) => conversation.ownerId === localUserStateId) || []; const conversationsSharedWithMe = - conversations?.filter((conversation) => conversation.ownerId !== localUser.id) || []; + conversations?.filter((conversation) => conversation.ownerId !== localUserStateId) || []; return ( diff --git a/workbench-app/src/routes/FrontDoor.tsx b/workbench-app/src/routes/FrontDoor.tsx index 3b821e54..f7159edf 100644 --- a/workbench-app/src/routes/FrontDoor.tsx +++ b/workbench-app/src/routes/FrontDoor.tsx @@ -78,7 +78,8 @@ const useClasses = makeStyles({ export const FrontDoor: React.FC = () => { const classes = useClasses(); const { conversationId } = useParams(); - const { activeConversationId, interactCanvasState } = useAppSelector((state) => state.app); + const activeConversationId = useAppSelector((state) => state.app.activeConversationId); + const chatCanvasState = useAppSelector((state) => state.chatCanvas); const dispatch = useDispatch(); const [sideRailLeftOpen, setSideRailLeftOpen] = React.useState(!activeConversationId && !conversationId); const [sideRailLeftOverlay, setSideRailLeftOverlay] = React.useState(false); @@ -100,13 +101,13 @@ export const FrontDoor: React.FC = () => { }, [conversationId, activeConversationId, dispatch]); React.useEffect(() => { - if (!interactCanvasState) return; + if (!chatCanvasState) return; - if (interactCanvasState.open) { + if (chatCanvasState.open) { setSideRailLeftOpen(false); } - setSideRailLeftOverlay(interactCanvasState.open ?? false); - }, [interactCanvasState, interactCanvasState?.open]); + setSideRailLeftOverlay(chatCanvasState.open ?? false); + }, [chatCanvasState, chatCanvasState?.open]); React.useEffect(() => { if (!sideRailLeftRef.current) return; @@ -134,7 +135,7 @@ export const FrontDoor: React.FC = () => { ); const globalContent = React.useMemo( - () => , + () => } />, [sideRailLeftButton], ); diff --git a/workbench-app/src/routes/Interact.tsx b/workbench-app/src/routes/Interact.tsx index 30594beb..8b1484e1 100644 --- a/workbench-app/src/routes/Interact.tsx +++ b/workbench-app/src/routes/Interact.tsx @@ -12,9 +12,9 @@ import { ConversationShare } from '../components/Conversations/ConversationShare import { InteractHistory } from '../components/Conversations/InteractHistory'; import { InteractInput } from '../components/Conversations/InteractInput'; import { useGetAssistantCapabilities } from '../libs/useAssistantCapabilities'; -import { useLocalUser } from '../libs/useLocalUser'; import { useSiteUtility } from '../libs/useSiteUtility'; import { Assistant } from '../models/Assistant'; +import { useAppSelector } from '../redux/app/hooks'; import { useGetAssistantsInConversationQuery, useGetConversationFilesQuery, @@ -97,7 +97,7 @@ export const Interact: React.FC = () => { error: conversationFilesError, isLoading: isLoadingConversationFiles, } = useGetConversationFilesQuery(conversationId); - const localUser = useLocalUser(); + const localUserId = useAppSelector((state) => state.localUser.id); const { data: assistantCapabilities, isFetching: isFetchingAssistantCapabilities } = useGetAssistantCapabilities( assistants ?? [], ); @@ -205,7 +205,7 @@ export const Interact: React.FC = () => { {conversation.title} diff --git a/workbench-app/src/routes/ShareRedeem.tsx b/workbench-app/src/routes/ShareRedeem.tsx index 108f8548..e679c72b 100644 --- a/workbench-app/src/routes/ShareRedeem.tsx +++ b/workbench-app/src/routes/ShareRedeem.tsx @@ -7,10 +7,10 @@ import { AppView } from '../components/App/AppView'; import { DialogControl } from '../components/App/DialogControl'; import { Loading } from '../components/App/Loading'; import { ConversationShareType, useConversationUtility } from '../libs/useConversationUtility'; -import { useLocalUser } from '../libs/useLocalUser'; import { useSiteUtility } from '../libs/useSiteUtility'; import { useWorkbenchService } from '../libs/useWorkbenchService'; import { Conversation } from '../models/Conversation'; +import { useAppSelector } from '../redux/app/hooks'; import { useCreateConversationMessageMutation, useGetConversationsQuery, @@ -28,7 +28,7 @@ export const ShareRedeem: React.FC = () => { const [submitted, setSubmitted] = React.useState(false); const [joinAttempted, setJoinAttempted] = React.useState(false); const conversationUtility = useConversationUtility(); - const localUser = useLocalUser(); + const localUserName = useAppSelector((state) => state.localUser.name); if (!conversationShareId) { throw new Error('Conversation Share ID is required'); @@ -64,7 +64,7 @@ export const ShareRedeem: React.FC = () => { if (conversationShare.conversationPermission === 'read_write') { await createConversationMessage({ conversationId: conversationShare.conversationId, - content: `${localUser.name} joined the conversation`, + content: `${localUserName} joined the conversation`, messageType: 'notice', }); } @@ -74,7 +74,7 @@ export const ShareRedeem: React.FC = () => { setSubmitted(false); } }, - [conversationShare, redeemShare, navigate, createConversationMessage, localUser.name], + [conversationShare, redeemShare, navigate, createConversationMessage, localUserName], ); const handleClickDuplicate = React.useCallback(async () => {