From 451fe7e87d08257b1237d0ecc662bebdad77d3ef Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 5 Nov 2024 17:19:27 -0800 Subject: [PATCH] pin config save/reset/actions to top of dialog, resizable canvas drawers (#217) --- workbench-app/.vscode/settings.json | 1 + .../src/components/App/CommandButton.tsx | 64 +++++- .../src/components/App/MenuItemControl.tsx | 43 ++++ .../Assistants/AssistantConfiguration.tsx | 21 +- .../Assistants/AssistantConfigure.tsx | 53 +++-- .../components/Assistants/AssistantRemove.tsx | 9 +- .../components/Assistants/AssistantRename.tsx | 62 ++++-- .../Assistants/AssistantServiceInfo.tsx | 34 +++ .../Assistants/AssistantServiceMetadata.tsx | 11 +- .../Conversations/InputOptionsControl.tsx | 45 ++-- .../Conversations/InteractInput.tsx | 3 +- .../Conversations/ParticipantItem.tsx | 124 +++++++++++ .../Conversations/ParticipantList.tsx | 45 +--- .../FrontDoor/Chat/AssistantDrawer.tsx | 36 +-- .../FrontDoor/Chat/CanvasDrawer.tsx | 209 +++++++++++++----- .../src/components/FrontDoor/Chat/Chat.tsx | 5 +- .../components/FrontDoor/Chat/ChatCanvas.tsx | 31 +-- .../FrontDoor/Chat/ConversationDrawer.tsx | 39 +--- workbench-app/src/libs/useMediaQuery.ts | 65 ++++++ workbench-app/src/routes/AssistantEditor.tsx | 28 +-- workbench-app/src/routes/FrontDoor.tsx | 100 +++++---- 21 files changed, 718 insertions(+), 310 deletions(-) create mode 100644 workbench-app/src/components/App/MenuItemControl.tsx create mode 100644 workbench-app/src/components/Assistants/AssistantServiceInfo.tsx create mode 100644 workbench-app/src/components/Conversations/ParticipantItem.tsx create mode 100644 workbench-app/src/libs/useMediaQuery.ts diff --git a/workbench-app/.vscode/settings.json b/workbench-app/.vscode/settings.json index aa545afe..656b97a9 100644 --- a/workbench-app/.vscode/settings.json +++ b/workbench-app/.vscode/settings.json @@ -157,6 +157,7 @@ "devcontainers", "devtunnel", "dotenv", + "dppx", "echosql", "endregion", "epivision", diff --git a/workbench-app/src/components/App/CommandButton.tsx b/workbench-app/src/components/App/CommandButton.tsx index 4c2c1363..2bcc7eef 100644 --- a/workbench-app/src/components/App/CommandButton.tsx +++ b/workbench-app/src/components/App/CommandButton.tsx @@ -1,32 +1,56 @@ // Copyright (c) Microsoft. All rights reserved. -import { Button, ButtonProps, ToolbarButton, Tooltip } from '@fluentui/react-components'; +import { + Button, + ButtonProps, + makeStyles, + mergeClasses, + tokens, + ToolbarButton, + Tooltip, +} from '@fluentui/react-components'; import React from 'react'; import { DialogControl, DialogControlContent } from './DialogControl'; +const useClasses = makeStyles({ + menuItem: { + paddingLeft: tokens.spacingHorizontalXS, + paddingRight: tokens.spacingHorizontalXS, + justifyContent: 'flex-start', + fontWeight: 'normal', + }, +}); + type CommandButtonProps = ButtonProps & { + className?: string; label?: string; description?: string; onClick?: () => void; dialogContent?: DialogControlContent; + open?: boolean; iconOnly?: boolean; asToolbarButton?: boolean; + simulateMenuItem?: boolean; }; export const CommandButton: React.FC = (props) => { const { as, + className, disabled, icon, label, description, onClick, dialogContent, + open, iconOnly, asToolbarButton, appearance, size, + simulateMenuItem, } = props; + const classes = useClasses(); let commandButton = null; @@ -40,12 +64,27 @@ export const CommandButton: React.FC = (props) => { } else { commandButton = dialogContent.trigger; } + } else if (simulateMenuItem) { + commandButton = ( + + ); } else if (iconOnly) { if (description) { commandButton = ( ); @@ -88,6 +143,7 @@ export const CommandButton: React.FC = (props) => { void; + iconOnly?: boolean; +}; + +export const MenuItemControl: React.FC = (props) => { + const { disabled, icon, label, description, onClick, iconOnly } = props; + + let menuItem = null; + + if (iconOnly) { + if (description) { + menuItem = ( + + + + ); + } else { + menuItem = ; + } + } else { + menuItem = ( + + {label} + + ); + if (description) { + menuItem = ( + + {menuItem} + + ); + } + } + return menuItem; +}; diff --git a/workbench-app/src/components/Assistants/AssistantConfiguration.tsx b/workbench-app/src/components/Assistants/AssistantConfiguration.tsx index 4ba9a631..5af2d4f6 100644 --- a/workbench-app/src/components/Assistants/AssistantConfiguration.tsx +++ b/workbench-app/src/components/Assistants/AssistantConfiguration.tsx @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -import { Button, Card, Divider, makeStyles, shorthands, Text, tokens } from '@fluentui/react-components'; +import { Button, Divider, makeStyles, shorthands, Text, tokens } from '@fluentui/react-components'; import { Warning24Filled } from '@fluentui/react-icons'; import Form from '@rjsf/fluentui-rc'; import { RegistryWidgetsType, RJSFSchema } from '@rjsf/utils'; @@ -26,8 +26,10 @@ import { AssistantConfigImportButton } from './AssistantConfigImportButton'; const log = debug(Constants.debug.root).extend('AssistantEdit'); const useClasses = makeStyles({ - card: { - backgroundImage: `linear-gradient(to right, ${tokens.colorNeutralBackground1}, ${tokens.colorBrandBackground2})`, + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, }, actions: { position: 'sticky', @@ -51,10 +53,11 @@ const useClasses = makeStyles({ interface AssistantConfigurationProps { assistant: Assistant; + onIsDirtyChange?: (isDirty: boolean) => void; } export const AssistantConfiguration: React.FC = (props) => { - const { assistant } = props; + const { assistant, onIsDirtyChange } = props; const classes = useClasses(); const { data: config, @@ -71,6 +74,12 @@ export const AssistantConfiguration: React.FC = (pr setConfigErrorMessage(configError ? JSON.stringify(configError) : undefined); }, [configError]); + React.useEffect(() => { + if (onIsDirtyChange) { + onIsDirtyChange(isDirty); + } + }, [isDirty, onIsDirtyChange]); + React.useEffect(() => { if (isLoadingConfig) return; setFormData(config?.config); @@ -137,7 +146,7 @@ export const AssistantConfiguration: React.FC = (pr } return ( - +
Assistant Configuration @@ -216,7 +225,7 @@ export const AssistantConfiguration: React.FC = (pr /> )} - +
); }; diff --git a/workbench-app/src/components/Assistants/AssistantConfigure.tsx b/workbench-app/src/components/Assistants/AssistantConfigure.tsx index eaaafa4a..0bb92a54 100644 --- a/workbench-app/src/components/Assistants/AssistantConfigure.tsx +++ b/workbench-app/src/components/Assistants/AssistantConfigure.tsx @@ -1,12 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -import { makeStyles, tokens } from '@fluentui/react-components'; +import { Button, makeStyles, tokens } from '@fluentui/react-components'; import { SettingsRegular } from '@fluentui/react-icons'; import React from 'react'; -import { useGetAssistantQuery } from '../../services/workbench'; +import { Assistant } from '../../models/Assistant'; import { CommandButton } from '../App/CommandButton'; import { AssistantConfiguration } from './AssistantConfiguration'; -import { AssistantServiceMetadata } from './AssistantServiceMetadata'; const useClasses = makeStyles({ dialogSurface: { @@ -19,6 +18,7 @@ const useClasses = makeStyles({ width: 'calc(min(1000px, 100vw) - 100px)', paddingRight: '8px', boxSizing: 'border-box', + overflowY: 'auto', }, content: { display: 'flex', @@ -28,47 +28,54 @@ const useClasses = makeStyles({ }); interface AssistantConfigureProps { - assistantId: string; + assistant: Assistant; + iconOnly?: boolean; disabled?: boolean; + simulateMenuItem?: boolean; } export const AssistantConfigure: React.FC = (props) => { - const { assistantId, disabled } = props; + const { assistant, iconOnly, disabled, simulateMenuItem } = props; const classes = useClasses(); - const { data: assistant, error: assistantError, isLoading: assistantLoading } = useGetAssistantQuery(assistantId); + const [open, setOpen] = React.useState(false); + const [isDirty, setIsDirty] = React.useState(false); - if (assistantError) { - const errorMessage = JSON.stringify(assistantError); - throw new Error(`Error loading assistant (${assistantId}): ${errorMessage}`); - } - - if (assistantLoading) { - return null; - } - - if (!assistant) { - throw new Error(`Assistant (${assistantId}) not found`); - } + const handleClose = React.useCallback(() => { + if (isDirty) { + const result = window.confirm('Are you sure you want to close without saving?'); + if (!result) { + return; + } + } + setOpen(false); + }, [isDirty]); return ( setOpen(true)} icon={} - iconOnly + simulateMenuItem={simulateMenuItem} + label="Configure" + iconOnly={iconOnly} disabled={disabled} - description={disabled ? `Workflow assistants cannot be configured` : 'Edit assistant configuration'} dialogContent={{ title: `Configure "${assistant.name}"`, content: (
- - +
), - closeLabel: 'Close', + hideDismissButton: true, classNames: { dialogSurface: classes.dialogSurface, dialogContent: classes.dialogContent, }, + additionalActions: [ + , + ], }} /> ); diff --git a/workbench-app/src/components/Assistants/AssistantRemove.tsx b/workbench-app/src/components/Assistants/AssistantRemove.tsx index 827d0caf..02a2d125 100644 --- a/workbench-app/src/components/Assistants/AssistantRemove.tsx +++ b/workbench-app/src/components/Assistants/AssistantRemove.tsx @@ -14,11 +14,13 @@ import { CommandButton } from '../App/CommandButton'; interface AssistantRemoveProps { participant: ConversationParticipant; conversation: Conversation; + iconOnly?: boolean; disabled?: boolean; + simulateMenuItem?: boolean; } export const AssistantRemove: React.FC = (props) => { - const { participant, conversation, disabled } = props; + const { participant, conversation, iconOnly, disabled, simulateMenuItem } = props; const [removeConversationParticipant] = useRemoveConversationParticipantMutation(); const [createConversationMessage] = useCreateConversationMessageMutation(); @@ -40,9 +42,10 @@ export const AssistantRemove: React.FC = (props) => { return ( } - iconOnly + simulateMenuItem={simulateMenuItem} + label="Remove" + iconOnly={iconOnly} disabled={disabled} - description={disabled ? `Workflow assistants cannot be removed` : 'Remove assistant from conversation'} dialogContent={{ title: `Remove "${participant.name}"`, content: ( diff --git a/workbench-app/src/components/Assistants/AssistantRename.tsx b/workbench-app/src/components/Assistants/AssistantRename.tsx index f59e52b8..65964355 100644 --- a/workbench-app/src/components/Assistants/AssistantRename.tsx +++ b/workbench-app/src/components/Assistants/AssistantRename.tsx @@ -1,51 +1,77 @@ // Copyright (c) Microsoft. All rights reserved. -import { DialogTrigger, Field, Input } from '@fluentui/react-components'; +import { Button, DialogTrigger, Field, Input } from '@fluentui/react-components'; import { EditRegular } from '@fluentui/react-icons'; import React from 'react'; +import { Assistant } from '../../models/Assistant'; +import { useGetConversationParticipantsQuery, useUpdateAssistantMutation } from '../../services/workbench'; import { CommandButton } from '../App/CommandButton'; interface AssistantRenameProps { - value: string; - onRename: (value: string) => Promise; + assistant: Assistant; + conversationId?: string; + iconOnly?: boolean; + simulateMenuItem?: boolean; + onRename?: (value: string) => Promise; } export const AssistantRename: React.FC = (props) => { - const { value, onRename } = props; - const [name, setName] = React.useState(value); + const { assistant, conversationId, iconOnly, simulateMenuItem, onRename } = props; + const [name, setName] = React.useState(assistant.name); const [submitted, setSubmitted] = React.useState(false); + const [open, setOpen] = React.useState(false); + const [updateAssistant] = useUpdateAssistantMutation(); + const { refetch: refetchParticipants } = useGetConversationParticipantsQuery(conversationId ?? '', { + skip: !conversationId, + }); - const handleRename = async () => { + const handleRename = React.useCallback(async () => { if (submitted) { return; } setSubmitted(true); - await onRename(name); + await updateAssistant({ ...assistant, name }); + + // Refetch participants to update the assistant name in the list + if (conversationId) { + await refetchParticipants(); + } + + await onRename?.(name); + setOpen(false); setSubmitted(false); - }; + }, [assistant, conversationId, name, onRename, refetchParticipants, submitted, updateAssistant]); return ( setOpen(true)} icon={} label="Rename" + iconOnly={iconOnly} + simulateMenuItem={simulateMenuItem} description="Rename assistant" dialogContent={{ title: 'Rename Assistant', content: ( - - setName(data.value)} /> - +
{ + event.preventDefault(); + handleRename(); + }} + > + + setName(data.value)} /> + +
, ], }} diff --git a/workbench-app/src/components/Assistants/AssistantServiceInfo.tsx b/workbench-app/src/components/Assistants/AssistantServiceInfo.tsx new file mode 100644 index 00000000..8d5c49b7 --- /dev/null +++ b/workbench-app/src/components/Assistants/AssistantServiceInfo.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { DatabaseRegular } from '@fluentui/react-icons'; +import React from 'react'; +import { Assistant } from '../../models/Assistant'; +import { CommandButton } from '../App/CommandButton'; +import { AssistantServiceMetadata } from './AssistantServiceMetadata'; + +interface AssistantServiceInfoProps { + assistant: Assistant; + iconOnly?: boolean; + disabled?: boolean; + simulateMenuItem?: boolean; +} + +export const AssistantServiceInfo: React.FC = (props) => { + const { assistant, iconOnly, disabled, simulateMenuItem } = props; + + return ( + } + simulateMenuItem={simulateMenuItem} + label="Service Info" + iconOnly={iconOnly} + description="View assistant service info" + disabled={disabled} + dialogContent={{ + title: `"${assistant.name}" Service Info`, + content: , + closeLabel: 'Close', + }} + /> + ); +}; diff --git a/workbench-app/src/components/Assistants/AssistantServiceMetadata.tsx b/workbench-app/src/components/Assistants/AssistantServiceMetadata.tsx index db232c7b..afd48f76 100644 --- a/workbench-app/src/components/Assistants/AssistantServiceMetadata.tsx +++ b/workbench-app/src/components/Assistants/AssistantServiceMetadata.tsx @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -import { Card, Label, Text, makeStyles, shorthands, tokens } from '@fluentui/react-components'; +import { Label, Text, makeStyles, tokens } from '@fluentui/react-components'; import React from 'react'; import { AssistantServiceRegistration } from '../../models/AssistantServiceRegistration'; import { useGetAssistantServiceRegistrationsQuery } from '../../services/workbench'; @@ -9,10 +9,7 @@ const useClasses = makeStyles({ root: { display: 'flex', flexDirection: 'column', - backgroundImage: `linear-gradient(to right, ${tokens.colorNeutralBackground1}, ${tokens.colorBrandBackground2})`, gap: tokens.spacingVerticalM, - borderRadius: tokens.borderRadiusMedium, - ...shorthands.padding(tokens.spacingVerticalM), }, data: { display: 'flex', @@ -54,9 +51,9 @@ export const AssistantServiceMetadata: React.FC = if (!assistantService) return null; return ( - +
- Assistant Backend + Assistant Backend Service
@@ -69,6 +66,6 @@ export const AssistantServiceMetadata: React.FC = Created by: {assistantService.createdByUserName} [{assistantService.createdByUserId}]
- +
); }; diff --git a/workbench-app/src/components/Conversations/InputOptionsControl.tsx b/workbench-app/src/components/Conversations/InputOptionsControl.tsx index 65ed5900..dceeb9e1 100644 --- a/workbench-app/src/components/Conversations/InputOptionsControl.tsx +++ b/workbench-app/src/components/Conversations/InputOptionsControl.tsx @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -import { Dropdown, makeStyles, mergeClasses, Option, tokens } from '@fluentui/react-components'; +import { Caption1, Dropdown, makeStyles, mergeClasses, Option, tokens } from '@fluentui/react-components'; import React from 'react'; import { ConversationParticipant } from '../../models/ConversationParticipant'; @@ -19,6 +19,9 @@ const useClasses = makeStyles({ width: '100%', maxWidth: '100%', }, + collapsible: { + minWidth: 'initial', + }, }); interface InputOptionsControlProps { @@ -52,28 +55,28 @@ export const InputOptionsControl: React.FC = (props) = return (
-
Message type: {messageTypeValue}
+
+ Mode: {messageTypeValue} +
-
Directed to:
-
- handleDirectedToChange(data.optionValue, data.optionText)} - > -
+ ))} +
); diff --git a/workbench-app/src/components/Conversations/InteractInput.tsx b/workbench-app/src/components/Conversations/InteractInput.tsx index 205752cc..95b224af 100644 --- a/workbench-app/src/components/Conversations/InteractInput.tsx +++ b/workbench-app/src/components/Conversations/InteractInput.tsx @@ -67,7 +67,8 @@ const useClasses = makeStyles({ width: '100%', maxWidth: `${Constants.app.maxContentWidth}px`, gap: tokens.spacingVerticalS, - ...shorthands.padding(0, tokens.spacingHorizontalXXL, 0, tokens.spacingHorizontalM), + + // ...shorthands.padding(0, tokens.spacingHorizontalXXL, 0, tokens.spacingHorizontalM), boxSizing: 'border-box', }, row: { diff --git a/workbench-app/src/components/Conversations/ParticipantItem.tsx b/workbench-app/src/components/Conversations/ParticipantItem.tsx new file mode 100644 index 00000000..ecc45c93 --- /dev/null +++ b/workbench-app/src/components/Conversations/ParticipantItem.tsx @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +import { + Button, + Menu, + MenuList, + MenuPopover, + MenuTrigger, + Persona, + makeStyles, + tokens, +} from '@fluentui/react-components'; +import { MoreHorizontalRegular } from '@fluentui/react-icons'; +import React from 'react'; +import { useParticipantUtility } from '../../libs/useParticipantUtility'; +import { Conversation } from '../../models/Conversation'; +import { ConversationParticipant } from '../../models/ConversationParticipant'; +import { useGetAssistantQuery } from '../../services/workbench'; +import { AssistantConfigure } from '../Assistants/AssistantConfigure'; +import { AssistantRemove } from '../Assistants/AssistantRemove'; +import { AssistantRename } from '../Assistants/AssistantRename'; +import { AssistantServiceInfo } from '../Assistants/AssistantServiceInfo'; + +const useClasses = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingHorizontalM, + }, + participant: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + actions: { + display: 'flex', + flexDirection: 'row', + gap: tokens.spacingHorizontalS, + }, +}); + +interface ParticipantItemProps { + conversation: Conversation; + participant: ConversationParticipant; + readOnly?: boolean; + preventAssistantModifyOnParticipantIds?: string[]; +} + +export const ParticipantItem: React.FC = (props) => { + const { conversation, participant, readOnly, preventAssistantModifyOnParticipantIds } = props; + const classes = useClasses(); + const { getAvatarData } = useParticipantUtility(); + + const { data: assistant, error: assistantError } = useGetAssistantQuery(participant.id, { + skip: participant.role !== 'assistant', + }); + + if (assistantError) { + const errorMessage = JSON.stringify(assistantError); + throw new Error(`Error loading assistant (${participant.id}): ${errorMessage}`); + } + + const assistantActions = React.useMemo(() => { + if (participant.role !== 'assistant') { + return null; + } + + return ( + + + + ); + }, [assistant, conversation, participant, preventAssistantModifyOnParticipantIds, readOnly]); + + return ( +
+ + {assistantActions} +
+ ); +}; diff --git a/workbench-app/src/components/Conversations/ParticipantList.tsx b/workbench-app/src/components/Conversations/ParticipantList.tsx index fac6ee70..be881156 100644 --- a/workbench-app/src/components/Conversations/ParticipantList.tsx +++ b/workbench-app/src/components/Conversations/ParticipantList.tsx @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -import { Persona, makeStyles, tokens } from '@fluentui/react-components'; +import { makeStyles, tokens } from '@fluentui/react-components'; import React from 'react'; import { useParticipantUtility } from '../../libs/useParticipantUtility'; import { Assistant } from '../../models/Assistant'; @@ -8,8 +8,7 @@ import { Conversation } from '../../models/Conversation'; import { ConversationParticipant } from '../../models/ConversationParticipant'; import { useAddConversationParticipantMutation, useCreateConversationMessageMutation } from '../../services/workbench'; import { AssistantAdd } from '../Assistants/AssistantAdd'; -import { AssistantConfigure } from '../Assistants/AssistantConfigure'; -import { AssistantRemove } from '../Assistants/AssistantRemove'; +import { ParticipantItem } from './ParticipantItem'; const useClasses = makeStyles({ root: { @@ -40,7 +39,8 @@ interface ParticipantListProps { export const ParticipantList: React.FC = (props) => { const { conversation, participants, preventAssistantModifyOnParticipantIds = [], readOnly } = props; const classes = useClasses(); - const { sortParticipants, getAvatarData } = useParticipantUtility(); + + const { sortParticipants } = useParticipantUtility(); const [addConversationParticipant] = useAddConversationParticipantMutation(); const [createConversationMessage] = useCreateConversationMessageMutation(); @@ -67,36 +67,13 @@ export const ParticipantList: React.FC = (props) => {
{sortParticipants(participants).map((participant) => ( -
- - {participant.role === 'assistant' && ( -
- - -
- )} -
+ ))}
); diff --git a/workbench-app/src/components/FrontDoor/Chat/AssistantDrawer.tsx b/workbench-app/src/components/FrontDoor/Chat/AssistantDrawer.tsx index 5392cbf1..c76d9d93 100644 --- a/workbench-app/src/components/FrontDoor/Chat/AssistantDrawer.tsx +++ b/workbench-app/src/components/FrontDoor/Chat/AssistantDrawer.tsx @@ -3,33 +3,23 @@ import React from 'react'; import { Assistant } from '../../../models/Assistant'; import { Conversation } from '../../../models/Conversation'; import { AssistantCanvasList } from '../../Conversations/Canvas/AssistantCanvasList'; -import { CanvasDrawer } from './CanvasDrawer'; +import { CanvasDrawer, CanvasDrawerOptions } from './CanvasDrawer'; const useClasses = makeStyles({ - drawer: { - backgroundImage: `linear-gradient(to right, ${tokens.colorNeutralBackground1}, ${tokens.colorBrandBackground2})`, - }, - drawerOpenInline: { - width: 'calc(100vw - 500px)', - }, - drawerOpenOverlay: { - width: '100%', - }, noContent: { padding: tokens.spacingHorizontalM, }, }); interface AssistantDrawerProps { - open: boolean; - mode: 'inline' | 'overlay'; + drawerOptions: CanvasDrawerOptions; conversation: Conversation; conversationAssistants?: Assistant[]; selectedAssistant?: Assistant; } export const AssistantDrawer: React.FC = (props) => { - const { open, mode, conversation, conversationAssistants, selectedAssistant } = props; + const { drawerOptions, conversation, conversationAssistants, selectedAssistant } = props; const classes = useClasses(); let title = ''; @@ -50,16 +40,14 @@ export const AssistantDrawer: React.FC = (props) => {
No assistants participating in conversation.
); - return ( - - {canvasContent} - + const options = React.useMemo( + () => ({ + ...drawerOptions, + title, + resizable: true, + }), + [drawerOptions, title], ); + + return {canvasContent}; }; diff --git a/workbench-app/src/components/FrontDoor/Chat/CanvasDrawer.tsx b/workbench-app/src/components/FrontDoor/Chat/CanvasDrawer.tsx index 1a6f9df1..acdc1a1a 100644 --- a/workbench-app/src/components/FrontDoor/Chat/CanvasDrawer.tsx +++ b/workbench-app/src/components/FrontDoor/Chat/CanvasDrawer.tsx @@ -1,78 +1,187 @@ -import { makeStyles, mergeClasses, shorthands, Title3, tokens } from '@fluentui/react-components'; +import { + DialogModalType, + Drawer, + DrawerBody, + DrawerHeader, + DrawerHeaderTitle, + makeStyles, + mergeClasses, + Title3, + tokens, +} from '@fluentui/react-components'; import React from 'react'; +import { Constants } from '../../../Constants'; +import { useAppDispatch, useAppSelector } from '../../../redux/app/hooks'; +import { setChatWidthPercent } from '../../../redux/features/app/appSlice'; const useClasses = makeStyles({ - drawerContainer: { - top: 0, - height: '100%', - transition: `width ${tokens.durationNormal} ${tokens.curveEasyEase}`, + root: { + position: 'relative', overflow: 'hidden', - backgroundColor: tokens.colorNeutralBackground1, - zIndex: tokens.zIndexContent, + display: 'flex', - flexDirection: 'column', - paddingTop: tokens.spacingVerticalXXL, + height: '100%', boxSizing: 'border-box', + backgroundColor: '#fff', + }, + drawer: { + willChange: 'width', + transitionProperty: 'width', + // approximate 60fps (1000ms / 60 = 16.666ms) + transitionDuration: '16.666ms', + }, + title: { + marginTop: tokens.spacingVerticalXL, + }, + resizer: { + width: tokens.spacingHorizontalS, + position: 'absolute', + top: 0, + bottom: 0, + cursor: 'col-resize', + resize: 'horizontal', + zIndex: tokens.zIndexContent, + boxSizing: 'border-box', + userSelect: 'none', - '&.left': { - ...shorthands.borderRight(tokens.strokeWidthThick, 'solid', tokens.colorNeutralStroke3), + // if drawer is coming from the right, set resizer on the left + '&.right': { + left: 0, + borderLeft: `${tokens.strokeWidthThick} solid ${tokens.colorNeutralStroke2}`, + + '&:hover': { + borderLeftWidth: tokens.strokeWidthThickest, + }, }, - '&.right': { - ...shorthands.borderLeft(tokens.strokeWidthThick, 'solid', tokens.colorNeutralStroke3), + // if drawer is coming from the left, set resizer on the right + '&.left': { + right: 0, + borderRight: `${tokens.strokeWidthThick} solid ${tokens.colorNeutralStroke2}`, + + '&:hover': { + borderRightWidth: tokens.strokeWidthThickest, + }, }, }, - drawerTitle: { - flexShrink: 0, - ...shorthands.padding( - tokens.spacingVerticalXXL, - tokens.spacingHorizontalXXL, - tokens.spacingVerticalS, - tokens.spacingHorizontalXXL, - ), - }, - drawerContent: { - flexGrow: 1, - padding: tokens.spacingHorizontalM, - overflow: 'auto', - '::-webkit-scrollbar-track': { - backgroundColor: tokens.colorNeutralBackground1, - }, - '::-webkit-scrollbar-thumb': { - backgroundColor: tokens.colorNeutralStencil1Alpha, - }, + resizerActive: { + borderRightWidth: '4px', + borderRightColor: tokens.colorNeutralBackground5Pressed, }, }); -interface CanvasDrawerProps { - openClassName?: string; - className?: string; +// create types for CanvasDrawer +export type CanvasDrawerSize = 'small' | 'medium' | 'large' | 'full'; +export type CanvasDrawerSide = 'left' | 'right'; +export type CanvasDrawerMode = 'inline' | 'overlay'; +export type CanvasDrawerOptions = { + classNames?: { + container?: string; + drawer?: string; + header?: string; + title?: string; + body?: string; + }; open?: boolean; - mode?: 'inline' | 'overlay'; - side?: 'left' | 'right'; title?: string | React.ReactNode; + size?: CanvasDrawerSize; + side?: CanvasDrawerSide; + mode?: CanvasDrawerMode; + modalType?: DialogModalType; + resizable?: boolean; +}; + +interface CanvasDrawerProps { + options: CanvasDrawerOptions; children?: React.ReactNode; } export const CanvasDrawer: React.FC = (props) => { - const { openClassName, className, open, mode, side, title, children } = props; + const { options, children } = props; + const { classNames, open, title, size, side, mode, modalType, resizable } = options; const classes = useClasses(); - - const drawerStyle: React.CSSProperties = { - right: side === 'right' ? 0 : undefined, - width: open ? undefined : '0px', - position: mode === 'inline' ? 'relative' : 'fixed', - }; + const animationFrame = React.useRef(0); + const sidebarRef = React.useRef(null); + const [isResizing, setIsResizing] = React.useState(false); + const chatWidthPercent = useAppSelector((state) => state.app.chatWidthPercent); + const dispatch = useAppDispatch(); const titleContent = typeof title === 'string' ? {title} : title; - return ( + const startResizing = React.useCallback(() => setIsResizing(true), []); + const stopResizing = React.useCallback(() => setIsResizing(false), []); + + const resize = React.useCallback( + (event: MouseEvent) => { + const { clientX } = event; + animationFrame.current = requestAnimationFrame(() => { + if (isResizing && sidebarRef.current) { + const clientRect = sidebarRef.current.getBoundingClientRect(); + const resizerPosition = side === 'left' ? clientRect.left : clientRect.right; + const desiredWidth = resizerPosition - clientX; + const desiredWidthPercent = (desiredWidth / window.innerWidth) * 100; + const minChatWidthPercent = Constants.app.minChatWidthPercent; + const maxWidth = Math.min(desiredWidthPercent, 100 - minChatWidthPercent); + const updatedChatWidthPercent = Math.max(minChatWidthPercent, maxWidth); + console.log( + `clientRect: ${clientRect}`, + `clientX: ${clientX}`, + `desiredWidth: ${desiredWidth}`, + `desiredWidthPercent: ${desiredWidthPercent}`, + `minChatWidthPercent: ${minChatWidthPercent}`, + `maxWidth: ${maxWidth}`, + `updatedChatWidthPercent: ${updatedChatWidthPercent}`, + ); + dispatch(setChatWidthPercent(updatedChatWidthPercent)); + } + }); + }, + [dispatch, isResizing, side], + ); + + const ResizeComponent: React.FC<{ className?: string }> = (props: { className?: string }) => (
-
{titleContent}
-
{children}
+ className={mergeClasses( + classes.resizer, + side ?? 'right', + isResizing && classes.resizerActive, + props.className, + )} + onMouseDown={startResizing} + /> + ); + + React.useEffect(() => { + window.addEventListener('mousemove', resize); + window.addEventListener('mouseup', stopResizing); + + return () => { + cancelAnimationFrame(animationFrame.current); + window.removeEventListener('mousemove', resize); + window.removeEventListener('mouseup', stopResizing); + }; + }, [resize, stopResizing]); + + return ( +
+ {resizable && } + + + + {titleContent} + + + {children} +
); }; diff --git a/workbench-app/src/components/FrontDoor/Chat/Chat.tsx b/workbench-app/src/components/FrontDoor/Chat/Chat.tsx index a7f01f07..c3458682 100644 --- a/workbench-app/src/components/FrontDoor/Chat/Chat.tsx +++ b/workbench-app/src/components/FrontDoor/Chat/Chat.tsx @@ -2,6 +2,7 @@ import { makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; +import { Constants } from '../../../Constants'; import { useGetAssistantCapabilities } from '../../../libs/useAssistantCapabilities'; import { useParticipantUtility } from '../../../libs/useParticipantUtility'; import { Assistant } from '../../../models/Assistant'; @@ -79,7 +80,7 @@ const useClasses = makeStyles({ flex: '1 1 auto', display: 'flex', flexDirection: 'column', - overflow: 'auto', + overflow: 'hidden', }, canvas: { flex: '0 0 auto', @@ -98,7 +99,7 @@ const useClasses = makeStyles({ historyContent: { // do not use flexbox here, it breaks the virtuoso width: '100%', - maxWidth: '800px', + maxWidth: `${Constants.app.maxContentWidth}px`, }, historyRoot: { paddingTop: tokens.spacingVerticalXXXL, diff --git a/workbench-app/src/components/FrontDoor/Chat/ChatCanvas.tsx b/workbench-app/src/components/FrontDoor/Chat/ChatCanvas.tsx index 978a5a6a..c2b6e273 100644 --- a/workbench-app/src/components/FrontDoor/Chat/ChatCanvas.tsx +++ b/workbench-app/src/components/FrontDoor/Chat/ChatCanvas.tsx @@ -36,26 +36,6 @@ export const ChatCanvas: React.FC = (props) => { const chatCanvasController = useChatCanvasController(); const [firstRun, setFirstRun] = React.useState(true); const [selectedAssistant, setSelectedAssistant] = React.useState(); - const [drawerMode, setDrawerMode] = React.useState<'inline' | 'overlay'>('inline'); - - const onMediaQueryChange = React.useCallback( - (matches: boolean) => setDrawerMode(matches ? 'overlay' : 'inline'), - [setDrawerMode], - ); - - React.useEffect(() => { - const mediaQuery = window.matchMedia(`(max-width: ${Constants.app.responsiveBreakpoints.chatCanvas})`); - - if (mediaQuery.matches) { - setDrawerMode('overlay'); - } - - mediaQuery.addEventListener('change', (event) => onMediaQueryChange(event.matches)); - - return () => { - mediaQuery.removeEventListener('change', (event) => onMediaQueryChange(event.matches)); - }; - }, [onMediaQueryChange]); // Set the selected assistant based on the chat canvas state React.useEffect(() => { @@ -107,12 +87,12 @@ export const ChatCanvas: React.FC = (props) => { // Determine which drawer to open, default to none const openDrawer = chatCanvasState.open ? chatCanvasState.mode : 'none'; - return ( <> = (props) => { preventAssistantModifyOnParticipantIds={preventAssistantModifyOnParticipantIds} /> = (props) => { const { - open, - mode, + drawerOptions, readOnly, conversation, conversationParticipants, conversationFiles, preventAssistantModifyOnParticipantIds, } = props; - const classes = useClasses(); + + const options: CanvasDrawerOptions = React.useMemo( + () => ({ + ...drawerOptions, + size: 'small', + }), + [drawerOptions], + ); return ( - + { + const query = buildMediaQuery(config); + const [matches, setMatches] = React.useState(false); + + React.useEffect(() => { + const mediaQueryList = window.matchMedia(query); + const documentChangeHandler = () => setMatches(mediaQueryList.matches); + + // Set the initial state + documentChangeHandler(); + + // Listen for changes + mediaQueryList.addEventListener('change', documentChangeHandler); + + return () => { + mediaQueryList.removeEventListener('change', documentChangeHandler); + }; + }, [query]); + + return matches; +}; + +type MediaQueryConfig = + | { minWidth: string | number } + | { maxWidth: string | number } + | { minHeight: string | number } + | { maxHeight: string | number } + | { query: string } + | { orientation: 'portrait' | 'landscape' } + | { resolution: 'high' } + | { aspectRatio: 'wide' | 'tall' } + | { device: 'screen' | 'print' }; + +export const buildMediaQuery = (config: MediaQueryConfig): string => { + if ('minWidth' in config) { + return `(min-width: ${typeof config.minWidth === 'number' ? `${config.minWidth}px` : config.minWidth})`; + } + if ('maxWidth' in config) { + return `(max-width: ${typeof config.maxWidth === 'number' ? `${config.maxWidth}px` : config.maxWidth})`; + } + if ('minHeight' in config) { + return `(min-height: ${typeof config.minHeight === 'number' ? `${config.minHeight}px` : config.minHeight})`; + } + if ('maxHeight' in config) { + return `(max-height: ${typeof config.maxHeight === 'number' ? `${config.maxHeight}px` : config.maxHeight})`; + } + if ('query' in config) { + return config.query; + } + if ('orientation' in config) { + return `(orientation: ${config.orientation})`; + } + if ('resolution' in config) { + return `(min-resolution: 2dppx)`; + } + if ('aspectRatio' in config) { + return config.aspectRatio === 'wide' ? `(min-aspect-ratio: 16/9)` : `(max-aspect-ratio: 1/1)`; + } + if ('device' in config) { + return config.device; + } + return ''; +}; diff --git a/workbench-app/src/routes/AssistantEditor.tsx b/workbench-app/src/routes/AssistantEditor.tsx index 6a0e7a7e..8970c842 100644 --- a/workbench-app/src/routes/AssistantEditor.tsx +++ b/workbench-app/src/routes/AssistantEditor.tsx @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -import { Title3, Toolbar, makeStyles, shorthands, tokens } from '@fluentui/react-components'; +import { Card, Title3, Toolbar, makeStyles, shorthands, tokens } from '@fluentui/react-components'; import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { AppView } from '../components/App/AppView'; @@ -13,7 +13,6 @@ import { AssistantRename } from '../components/Assistants/AssistantRename'; import { AssistantServiceMetadata } from '../components/Assistants/AssistantServiceMetadata'; import { MyConversations } from '../components/Conversations/MyConversations'; import { useSiteUtility } from '../libs/useSiteUtility'; -import { Assistant } from '../models/Assistant'; import { Conversation } from '../models/Conversation'; import { useAppSelector } from '../redux/app/hooks'; import { @@ -21,7 +20,6 @@ import { useCreateConversationMessageMutation, useGetAssistantConversationsQuery, useGetAssistantQuery, - useUpdateAssistantMutation, } from '../services/workbench'; const useClasses = makeStyles({ @@ -50,6 +48,9 @@ const useClasses = makeStyles({ backgroundColor: tokens.colorNeutralBackgroundAlpha, borderRadius: tokens.borderRadiusMedium, }, + card: { + backgroundImage: `linear-gradient(to right, ${tokens.colorNeutralBackground1}, ${tokens.colorBrandBackground2})`, + }, }); export const AssistantEditor: React.FC = () => { @@ -65,7 +66,6 @@ export const AssistantEditor: React.FC = () => { isLoading: isLoadingAssistantConversations, } = useGetAssistantConversationsQuery(assistantId); const { data: assistant, error: assistantError, isLoading: isLoadingAssistant } = useGetAssistantQuery(assistantId); - const [updateAssistant] = useUpdateAssistantMutation(); const [addConversationParticipant] = useAddConversationParticipantMutation(); const [createConversationMessage] = useCreateConversationMessageMutation(); const localUserName = useAppSelector((state) => state.localUser.name); @@ -90,16 +90,6 @@ export const AssistantEditor: React.FC = () => { siteUtility.setDocumentTitle(`Edit ${assistant.name}`); }, [assistantId, assistant, isLoadingAssistant, siteUtility]); - const handleRename = React.useCallback( - async (newName: string) => { - if (!assistant) { - throw new Error('Assistant is not set, unable to update name'); - } - await updateAssistant({ ...assistant, name: newName } as Assistant); - }, - [assistant, updateAssistant], - ); - const handleDelete = React.useCallback(() => { // navigate to site root siteUtility.forceNavigateTo('/'); @@ -143,14 +133,16 @@ export const AssistantEditor: React.FC = () => { - + {assistant.name}
} >
- + + + { hideInstruction onCreate={handleConversationCreate} /> - + + +
diff --git a/workbench-app/src/routes/FrontDoor.tsx b/workbench-app/src/routes/FrontDoor.tsx index f7159edf..bfadc7a0 100644 --- a/workbench-app/src/routes/FrontDoor.tsx +++ b/workbench-app/src/routes/FrontDoor.tsx @@ -1,4 +1,4 @@ -import { Button, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; +import { Button, Drawer, DrawerBody, makeStyles, mergeClasses, shorthands, tokens } from '@fluentui/react-components'; import { PanelLeftContractRegular, PanelLeftExpandRegular } from '@fluentui/react-icons'; import React from 'react'; import { useDispatch } from 'react-redux'; @@ -8,6 +8,7 @@ import { NewConversationButton } from '../components/FrontDoor/Controls/NewConve import { SiteMenuButton } from '../components/FrontDoor/Controls/SiteMenuButton'; import { GlobalContent } from '../components/FrontDoor/GlobalContent'; import { MainContent } from '../components/FrontDoor/MainContent'; +import { useMediaQuery } from '../libs/useMediaQuery'; import { useAppSelector } from '../redux/app/hooks'; import { setActiveConversationId } from '../redux/features/app/appSlice'; @@ -28,23 +29,18 @@ const useClasses = makeStyles({ height: '100%', }, sideRailLeft: { - width: '0px', - flex: '0 0 auto', - overflow: 'hidden', backgroundColor: tokens.colorNeutralBackground2, ...shorthands.borderRight(tokens.strokeWidthThick, 'solid', tokens.colorNeutralStroke3), - ...shorthands.transition('width', tokens.durationSlow, '0', tokens.curveEasyEase), - - '&.open': { - width: '300px', + boxSizing: 'border-box', + }, + sideRailLeftBody: { + // override Fluent UI DrawerBody padding + padding: 0, + '&:first-child': { + paddingTop: 0, }, - - '&.overlay': { - position: 'absolute', - zIndex: tokens.zIndexFloating, - height: '100%', - borderRight: 'none', - boxShadow: tokens.shadow8Brand, + '&:last-child': { + paddingBottom: 0, }, }, transitionFade: { @@ -81,10 +77,11 @@ export const FrontDoor: React.FC = () => { const activeConversationId = useAppSelector((state) => state.app.activeConversationId); const chatCanvasState = useAppSelector((state) => state.chatCanvas); const dispatch = useDispatch(); + const sideRailLeftRef = React.useRef(null); const [sideRailLeftOpen, setSideRailLeftOpen] = React.useState(!activeConversationId && !conversationId); - const [sideRailLeftOverlay, setSideRailLeftOverlay] = React.useState(false); const [isInitialized, setIsInitialized] = React.useState(false); - const sideRailLeftRef = React.useRef(null); + const isSmall = useMediaQuery({ maxWidth: 720 }); + const [sideRailLeftType, setSideRailLeftType] = React.useState<'inline' | 'overlay'>('inline'); React.useEffect(() => { document.body.className = classes.documentBody; @@ -94,46 +91,55 @@ export const FrontDoor: React.FC = () => { }, [classes.documentBody]); React.useEffect(() => { - if (conversationId && conversationId !== activeConversationId) { + if (conversationId !== activeConversationId) { dispatch(setActiveConversationId(conversationId)); } setIsInitialized(true); }, [conversationId, activeConversationId, dispatch]); - React.useEffect(() => { - if (!chatCanvasState) return; - - if (chatCanvasState.open) { - setSideRailLeftOpen(false); - } - setSideRailLeftOverlay(chatCanvasState.open ?? false); - }, [chatCanvasState, chatCanvasState?.open]); - - React.useEffect(() => { - if (!sideRailLeftRef.current) return; + const handleClickOutside = React.useCallback( + (event: MouseEvent) => { + if (!sideRailLeftRef.current) return; - const handleOutsideClick = (event: MouseEvent) => { - if (sideRailLeftOpen && sideRailLeftOverlay && !sideRailLeftRef.current?.contains(event.target as Node)) { + if (!sideRailLeftRef.current.contains(event.target as HTMLElement)) { setSideRailLeftOpen(false); } - }; + }, + [sideRailLeftRef], + ); - document.addEventListener('mousedown', handleOutsideClick); - return () => { - document.removeEventListener('mousedown', handleOutsideClick); - }; - }, [sideRailLeftOpen, sideRailLeftOverlay]); + React.useEffect(() => { + if (sideRailLeftOpen && sideRailLeftType === 'overlay') { + document.addEventListener('click', handleClickOutside); + } else { + document.removeEventListener('click', handleClickOutside); + } + return () => document.removeEventListener('click', handleClickOutside); + }, [handleClickOutside, sideRailLeftOpen, sideRailLeftRef, sideRailLeftType]); const sideRailLeftButton = React.useMemo( () => (