From 3852a13260752477e10afc07f7ed1cdbcf1d2967 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Fri, 8 Nov 2024 09:08:48 -0800 Subject: [PATCH] holds dialogs open to show status for async actions, fix broken behavior for marking messages/conversations read (#230) --- .../src/components/App/ContentExport.tsx | 19 +++- .../src/components/App/ContentImport.tsx | 2 +- ...ssistantServiceRegistrationApiKeyReset.tsx | 32 ++++-- .../AssistantServiceRegistrationCreate.tsx | 16 ++- .../AssistantServiceRegistrationRemove.tsx | 16 ++- .../Assistants/ApplyConfigButton.tsx | 4 +- .../components/Assistants/AssistantDelete.tsx | 20 +++- .../Assistants/AssistantDuplicate.tsx | 16 ++- .../components/Assistants/AssistantImport.tsx | 29 ++--- .../components/Assistants/AssistantRemove.tsx | 44 +++++--- .../Conversations/ConversationCreate.tsx | 4 +- .../Conversations/ConversationDuplicate.tsx | 102 ++++++++++------- .../Conversations/ConversationExport.tsx | 57 ++++++++++ .../Conversations/ConversationRemove.tsx | 59 +++++++--- .../Conversations/ConversationRename.tsx | 104 +++++++++++------- .../Conversations/ConversationShare.tsx | 11 +- .../Conversations/ConversationShareCreate.tsx | 47 ++++---- .../Conversations/ConversationTranscript.tsx | 31 ++++-- .../src/components/Conversations/FileItem.tsx | 93 ++++++++++------ .../Conversations/InteractHistory.tsx | 6 +- .../Conversations/MessageDelete.tsx | 20 +++- .../Conversations/MyConversations.tsx | 4 +- .../src/components/Conversations/MyShares.tsx | 4 +- .../Conversations/RewindConversation.tsx | 26 ++++- .../components/Conversations/ShareRemove.tsx | 18 ++- .../FrontDoor/Controls/ConversationItem.tsx | 32 ++++-- .../FrontDoor/Controls/ConversationList.tsx | 47 +++++--- .../Controls/ConversationListOptions.tsx | 14 +-- .../Controls/NewConversationButton.tsx | 2 +- .../components/Workflows/WorkflowCreate.tsx | 2 + .../AssistantDefinitionCreate.tsx | 34 ++++-- .../ConversationDefinitionCreate.tsx | 30 +++-- .../Workflows/WorkflowRunCreate.tsx | 23 ++-- workbench-app/src/libs/Utility.ts | 10 -- .../src/libs/useConversationUtility.ts | 40 ++++--- workbench-app/src/routes/Interact.tsx | 2 +- 36 files changed, 685 insertions(+), 335 deletions(-) diff --git a/workbench-app/src/components/App/ContentExport.tsx b/workbench-app/src/components/App/ContentExport.tsx index c3ae15cf..7ac0d3ca 100644 --- a/workbench-app/src/components/App/ContentExport.tsx +++ b/workbench-app/src/components/App/ContentExport.tsx @@ -16,6 +16,20 @@ interface ContentExportProps { export const ContentExport: React.FC = (props) => { const { id, contentTypeLabel, exportFunction, iconOnly, asToolbarButton } = props; const { exportContent } = useExportUtility(); + const [exporting, setExporting] = React.useState(false); + + const handleExport = React.useCallback(async () => { + if (exporting) { + return; + } + setExporting(true); + + try { + await exportContent(id, exportFunction); + } finally { + setExporting(false); + } + }, [exporting, exportContent, id, exportFunction]); return ( = (props) => { icon={} iconOnly={iconOnly} asToolbarButton={asToolbarButton} - label="Export" - onClick={() => exportContent(id, exportFunction)} + label={exporting ? 'Exporting...' : 'Export'} + onClick={handleExport} + disabled={exporting} /> ); }; diff --git a/workbench-app/src/components/App/ContentImport.tsx b/workbench-app/src/components/App/ContentImport.tsx index c799ae0e..016a34d2 100644 --- a/workbench-app/src/components/App/ContentImport.tsx +++ b/workbench-app/src/components/App/ContentImport.tsx @@ -64,7 +64,7 @@ export const ContentImport = (props: ContentImportProps) = asToolbarButton={asToolbarButton} appearance={appearance} size={size} - label="Import" + label={uploading ? 'Uploading...' : 'Import'} onClick={onUpload} /> diff --git a/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationApiKeyReset.tsx b/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationApiKeyReset.tsx index eb1a16a7..d74573b0 100644 --- a/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationApiKeyReset.tsx +++ b/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationApiKeyReset.tsx @@ -24,22 +24,30 @@ export const AssistantServiceRegistrationApiKeyReset: React.FC(undefined); const handleReset = React.useCallback(async () => { + if (submitted) { + return; + } setSubmitted(true); - let updatedRegistration: AssistantServiceRegistration | undefined; + try { - updatedRegistration = await resetAssistantServiceRegistrationApiKey( - assistantServiceRegistration.assistantServiceId, - ).unwrap(); + let updatedRegistration: AssistantServiceRegistration | undefined; + try { + updatedRegistration = await resetAssistantServiceRegistrationApiKey( + assistantServiceRegistration.assistantServiceId, + ).unwrap(); + } finally { + setSubmitted(false); + } + + if (updatedRegistration) { + setUnmaskedApiKey(updatedRegistration.apiKey); + } + + onRemove?.(); } finally { setSubmitted(false); } - - if (updatedRegistration) { - setUnmaskedApiKey(updatedRegistration.apiKey); - } - - onRemove?.(); - }, [assistantServiceRegistration.assistantServiceId, resetAssistantServiceRegistrationApiKey, onRemove]); + }, [submitted, onRemove, resetAssistantServiceRegistrationApiKey, assistantServiceRegistration.assistantServiceId]); return ( <> @@ -55,7 +63,7 @@ export const AssistantServiceRegistrationApiKeyReset: React.FC} iconOnly={iconOnly} asToolbarButton={asToolbarButton} - label="Reset" + label={submitted ? 'Resetting...' : 'Reset'} dialogContent={{ title: 'Reset API Key', content: ( diff --git a/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationCreate.tsx b/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationCreate.tsx index ea973ad8..9f8cb1cb 100644 --- a/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationCreate.tsx +++ b/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationCreate.tsx @@ -51,7 +51,7 @@ export const AssistantServiceRegistrationCreate: React.FC(); - const handleSave = async () => { + const handleSave = React.useCallback(async () => { if (submitted) { return; } @@ -75,7 +75,17 @@ export const AssistantServiceRegistrationCreate: React.FC { setValid(false); @@ -164,7 +174,7 @@ export const AssistantServiceRegistrationCreate: React.FC , ]} diff --git a/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationRemove.tsx b/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationRemove.tsx index cc59d15f..8015e775 100644 --- a/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationRemove.tsx +++ b/workbench-app/src/components/AssistantServiceRegistrations/AssistantServiceRegistrationRemove.tsx @@ -17,15 +17,25 @@ interface AssistantServiceRegistrationRemoveProps { export const AssistantServiceRegistrationRemove: React.FC = (props) => { const { assistantServiceRegistration, onRemove, iconOnly, asToolbarButton } = props; const [removeAssistantServiceRegistration] = useRemoveAssistantServiceRegistrationMutation(); + const [submitted, setSubmitted] = React.useState(false); if (!assistantServiceRegistration) { throw new Error(`Assistant service registration not found`); } const handleAssistantServiceRegistrationRemove = React.useCallback(async () => { - await removeAssistantServiceRegistration(assistantServiceRegistration.assistantServiceId); - onRemove?.(); - }, [assistantServiceRegistration, onRemove, removeAssistantServiceRegistration]); + if (submitted) { + return; + } + setSubmitted(true); + + try { + await removeAssistantServiceRegistration(assistantServiceRegistration.assistantServiceId); + onRemove?.(); + } finally { + setSubmitted(false); + } + }, [assistantServiceRegistration.assistantServiceId, onRemove, removeAssistantServiceRegistration, submitted]); return ( = (props) => { } }, [currentConfig, newConfig]); - const handleApply = () => { + const handleApply = React.useCallback(() => { onApply?.(newConfig); - }; + }, [newConfig, onApply]); const defaultLabel = 'Apply configuration'; const title = `${label ?? defaultLabel}: ${diffCount} changes`; diff --git a/workbench-app/src/components/Assistants/AssistantDelete.tsx b/workbench-app/src/components/Assistants/AssistantDelete.tsx index b934c2e4..199718f6 100644 --- a/workbench-app/src/components/Assistants/AssistantDelete.tsx +++ b/workbench-app/src/components/Assistants/AssistantDelete.tsx @@ -17,11 +17,21 @@ interface AssistantDeleteProps { export const AssistantDelete: React.FC = (props) => { const { assistant, onDelete, iconOnly, asToolbarButton } = props; const [deleteAssistant] = useDeleteAssistantMutation(); + const [submitted, setSubmitted] = React.useState(false); const handleDelete = React.useCallback(async () => { - await deleteAssistant(assistant.id); - onDelete?.(); - }, [assistant, onDelete, deleteAssistant]); + if (submitted) { + return; + } + setSubmitted(true); + + try { + await deleteAssistant(assistant.id); + onDelete?.(); + } finally { + setSubmitted(false); + } + }, [submitted, deleteAssistant, assistant.id, onDelete]); return ( = (props) => { closeLabel: 'Cancel', additionalActions: [ - , ], diff --git a/workbench-app/src/components/Assistants/AssistantDuplicate.tsx b/workbench-app/src/components/Assistants/AssistantDuplicate.tsx index dba85af1..ecb1c677 100644 --- a/workbench-app/src/components/Assistants/AssistantDuplicate.tsx +++ b/workbench-app/src/components/Assistants/AssistantDuplicate.tsx @@ -18,15 +18,23 @@ interface AssistantDuplicateProps { export const AssistantDuplicate: React.FC = (props) => { const { assistant, iconOnly, asToolbarButton, onDuplicate, onDuplicateError } = props; const workbenchService = useWorkbenchService(); + const [submitted, setSubmitted] = React.useState(false); + + const duplicateAssistant = React.useCallback(async () => { + if (submitted) { + return; + } + setSubmitted(true); - const duplicateAssistant = async () => { try { const newAssistantId = await workbenchService.duplicateAssistantAsync(assistant.id); onDuplicate?.(newAssistantId); } catch (error) { onDuplicateError?.(error as Error); + } finally { + setSubmitted(false); } - }; + }, [submitted, workbenchService, assistant.id, onDuplicate, onDuplicateError]); return ( = (props) => closeLabel: 'Cancel', additionalActions: [ - , ], diff --git a/workbench-app/src/components/Assistants/AssistantImport.tsx b/workbench-app/src/components/Assistants/AssistantImport.tsx index d00fdb6b..19c7c2b0 100644 --- a/workbench-app/src/components/Assistants/AssistantImport.tsx +++ b/workbench-app/src/components/Assistants/AssistantImport.tsx @@ -21,20 +21,23 @@ export const AssistantImport: React.FC = (props) => { const workbenchService = useWorkbenchService(); const onFileChange = async (event: React.ChangeEvent) => { - if (event.target.files) { - setUploading(true); - try { - const file = event.target.files[0]; - const result = await workbenchService.importConversationsAsync(file); - onImport?.(result); - } catch (error) { - onError?.(error as Error); - } - setUploading(false); + if (uploading || !event.target.files) { + return; } + setUploading(true); + + try { + const file = event.target.files[0]; + const result = await workbenchService.importConversationsAsync(file); + onImport?.(result); - if (fileInputRef.current) { - fileInputRef.current.value = ''; + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } catch (error) { + onError?.(error as Error); + } finally { + setUploading(false); } }; @@ -51,7 +54,7 @@ export const AssistantImport: React.FC = (props) => { icon={} iconOnly={iconOnly} asToolbarButton={asToolbarButton} - label={label ?? 'Import'} + label={label ?? (uploading ? 'Uploading...' : 'Import')} onClick={onUpload} /> diff --git a/workbench-app/src/components/Assistants/AssistantRemove.tsx b/workbench-app/src/components/Assistants/AssistantRemove.tsx index 02a2d125..de610de4 100644 --- a/workbench-app/src/components/Assistants/AssistantRemove.tsx +++ b/workbench-app/src/components/Assistants/AssistantRemove.tsx @@ -23,21 +23,38 @@ export const AssistantRemove: React.FC = (props) => { const { participant, conversation, iconOnly, disabled, simulateMenuItem } = props; const [removeConversationParticipant] = useRemoveConversationParticipantMutation(); const [createConversationMessage] = useCreateConversationMessageMutation(); + const [submitted, setSubmitted] = React.useState(false); - const handleAssistantRemove = async () => { - await removeConversationParticipant({ - conversationId: conversation.id, - participantId: participant.id, - }); + const handleAssistantRemove = React.useCallback(async () => { + if (submitted) { + return; + } + setSubmitted(true); - const content = `${participant.name} removed from conversation`; + try { + await removeConversationParticipant({ + conversationId: conversation.id, + participantId: participant.id, + }); - await createConversationMessage({ - conversationId: conversation.id, - content, - messageType: 'notice', - }); - }; + const content = `${participant.name} removed from conversation`; + + await createConversationMessage({ + conversationId: conversation.id, + content, + messageType: 'notice', + }); + } finally { + setSubmitted(false); + } + }, [ + conversation.id, + createConversationMessage, + participant.id, + participant.name, + removeConversationParticipant, + submitted, + ]); return ( = (props) => { } - label="Remove" + disabled={submitted} + label={submitted ? 'Removing...' : 'Remove'} onClick={handleAssistantRemove} /> , diff --git a/workbench-app/src/components/Conversations/ConversationCreate.tsx b/workbench-app/src/components/Conversations/ConversationCreate.tsx index a87cd221..cc8d7a5e 100644 --- a/workbench-app/src/components/Conversations/ConversationCreate.tsx +++ b/workbench-app/src/components/Conversations/ConversationCreate.tsx @@ -39,7 +39,7 @@ export const ConversationCreate: React.FC = (props) => const [title, setTitle] = React.useState(''); const [submitted, setSubmitted] = React.useState(false); - const handleSave = async () => { + const handleSave = React.useCallback(async () => { if (submitted) { return; } @@ -52,7 +52,7 @@ export const ConversationCreate: React.FC = (props) => } finally { setSubmitted(false); } - }; + }, [createConversation, metadata, onCreate, onOpenChange, submitted, title]); React.useEffect(() => { if (!open) { diff --git a/workbench-app/src/components/Conversations/ConversationDuplicate.tsx b/workbench-app/src/components/Conversations/ConversationDuplicate.tsx index b3fcc59b..22598585 100644 --- a/workbench-app/src/components/Conversations/ConversationDuplicate.tsx +++ b/workbench-app/src/components/Conversations/ConversationDuplicate.tsx @@ -1,39 +1,49 @@ // Copyright (c) Microsoft. All rights reserved. -import { Button, DialogTrigger } from '@fluentui/react-components'; +import { Button, DialogOpenChangeData, DialogOpenChangeEvent, DialogTrigger } from '@fluentui/react-components'; import { SaveCopy24Regular } from '@fluentui/react-icons'; import React from 'react'; +import { useNotify } from '../../libs/useNotify'; import { useWorkbenchService } from '../../libs/useWorkbenchService'; -import { Conversation } from '../../models/Conversation'; +import { Utility } from '../../libs/Utility'; import { CommandButton } from '../App/CommandButton'; import { DialogControl } from '../App/DialogControl'; -const useConversationDuplicateControls = (ids: string[]) => { +const useConversationDuplicateControls = (id: string) => { const workbenchService = useWorkbenchService(); + const [submitted, setSubmitted] = React.useState(false); - const duplicateConversations = async ( - onDuplicate?: (conversationId: string) => void, - onDuplicateError?: (error: Error) => void, - ) => { - try { - const duplicates = await workbenchService.duplicateConversationsAsync(ids); - duplicates.forEach((duplicate) => onDuplicate?.(duplicate)); - } catch (error) { - onDuplicateError?.(error as Error); - } - }; + const duplicateConversation = React.useCallback( + async (onDuplicate?: (conversationId: string) => Promise, onError?: (error: Error) => void) => { + try { + await Utility.withStatus(setSubmitted, async () => { + const duplicates = await workbenchService.duplicateConversationsAsync([id]); + await onDuplicate?.(duplicates[0]); + }); + } catch (error) { + onError?.(error as Error); + } + }, + [id, workbenchService], + ); - const duplicateConversationForm = () =>

Are you sure you want to duplicate this conversation?

; + const duplicateConversationForm = React.useCallback( + () =>

Are you sure you want to duplicate this conversation?

, + [], + ); - const duplicateConversationButton = ( - onDuplicate?: (conversationId: string) => void, - onDuplicateError?: (error: Error) => void, - ) => ( - - - + ), + [duplicateConversation, submitted], ); return { @@ -43,40 +53,52 @@ const useConversationDuplicateControls = (ids: string[]) => { }; interface ConversationDuplicateDialogProps { - id: string; - onDuplicate: (conversationId: string) => void; - onCancel: () => void; + conversationId: string; + onDuplicate: (conversationId: string) => Promise; + open?: boolean; + onOpenChange: (event: DialogOpenChangeEvent, data: DialogOpenChangeData) => void; } export const ConversationDuplicateDialog: React.FC = (props) => { - const { id, onDuplicate, onCancel } = props; - const { duplicateConversationForm, duplicateConversationButton } = useConversationDuplicateControls([id]); + const { conversationId, onDuplicate, open, onOpenChange } = props; + const { duplicateConversationForm, duplicateConversationButton } = useConversationDuplicateControls(conversationId); + const { notifyWarning } = useNotify(); + + const handleError = React.useCallback( + (error: Error) => { + notifyWarning({ + id: 'error', + title: 'Duplicate conversation failed', + message: error.message, + }); + }, + [notifyWarning], + ); return ( ); }; interface ConversationDuplicateProps { - conversation: Conversation; + conversationId: string; + disabled?: boolean; iconOnly?: boolean; asToolbarButton?: boolean; - onDuplicate?: (conversationId: string) => void; + onDuplicate?: (conversationId: string) => Promise; onDuplicateError?: (error: Error) => void; } export const ConversationDuplicate: React.FC = (props) => { - const { conversation, iconOnly, asToolbarButton, onDuplicate, onDuplicateError } = props; - const { duplicateConversationForm, duplicateConversationButton } = useConversationDuplicateControls([ - conversation.id, - ]); + const { conversationId, iconOnly, asToolbarButton, onDuplicate, onDuplicateError } = props; + const { duplicateConversationForm, duplicateConversationButton } = useConversationDuplicateControls(conversationId); return ( = (prop title: 'Duplicate conversation', content: duplicateConversationForm(), closeLabel: 'Cancel', - additionalActions: [duplicateConversationButton(onDuplicate, onDuplicateError)], + additionalActions: [ + + {duplicateConversationButton(onDuplicate, onDuplicateError)} + , + ], }} /> ); diff --git a/workbench-app/src/components/Conversations/ConversationExport.tsx b/workbench-app/src/components/Conversations/ConversationExport.tsx index af9b68ce..3c52f1ca 100644 --- a/workbench-app/src/components/Conversations/ConversationExport.tsx +++ b/workbench-app/src/components/Conversations/ConversationExport.tsx @@ -1,8 +1,65 @@ // Copyright (c) Microsoft. All rights reserved. +import { ProgressBar } from '@fluentui/react-components'; import React from 'react'; import { useExportUtility } from '../../libs/useExportUtility'; +import { useNotify } from '../../libs/useNotify'; +import { Utility } from '../../libs/Utility'; import { ContentExport } from '../App/ContentExport'; +import { DialogControl } from '../App/DialogControl'; + +interface ConversationExportWithStatusDialogProps { + conversationId?: string; + onExport: (id: string) => Promise; +} + +export const ConversationExportWithStatusDialog: React.FC = (props) => { + const { conversationId, onExport } = props; + const { exportConversation } = useExportUtility(); + const { notifyWarning } = useNotify(); + const [submitted, setSubmitted] = React.useState(false); + + const handleError = React.useCallback( + (error: Error) => { + notifyWarning({ + id: 'error', + title: 'Export conversation failed', + message: error.message, + }); + }, + [notifyWarning], + ); + + React.useEffect(() => { + if (!conversationId) { + return; + } + + (async () => { + try { + await Utility.withStatus(setSubmitted, async () => { + await exportConversation(conversationId); + await onExport(conversationId); + }); + } catch (error) { + handleError(error as Error); + } + })(); + }, [conversationId, exportConversation, handleError, notifyWarning, onExport]); + + return ( + + +

+ } + /> + ); +}; interface ConversationExportProps { conversationId: string; diff --git a/workbench-app/src/components/Conversations/ConversationRemove.tsx b/workbench-app/src/components/Conversations/ConversationRemove.tsx index f139ae57..8b081e68 100644 --- a/workbench-app/src/components/Conversations/ConversationRemove.tsx +++ b/workbench-app/src/components/Conversations/ConversationRemove.tsx @@ -14,28 +14,51 @@ const useConversationRemoveControls = () => { const activeConversationId = useAppSelector((state) => state.app.activeConversationId); const dispatch = useAppDispatch(); const [removeConversationParticipant] = useRemoveConversationParticipantMutation(); + const [submitted, setSubmitted] = React.useState(false); - const handleRemove = async (conversationId: string, participantId: string, onRemove?: () => void) => { - if (activeConversationId === conversationId) { - // Clear the active conversation if it is the one being removed - dispatch(setActiveConversationId(undefined)); - } + const handleRemove = React.useCallback( + async (conversationId: string, participantId: string, onRemove?: () => void) => { + if (submitted) { + return; + } + setSubmitted(true); - await removeConversationParticipant({ - conversationId, - participantId, - }); - onRemove?.(); - }; + try { + if (activeConversationId === conversationId) { + // Clear the active conversation if it is the one being removed + dispatch(setActiveConversationId(undefined)); + } + + await removeConversationParticipant({ + conversationId, + participantId, + }); + onRemove?.(); + } finally { + setSubmitted(false); + } + }, + [activeConversationId, dispatch, removeConversationParticipant, submitted], + ); - const removeConversationForm = () =>

Are you sure you want to remove this conversation from your list?

; + const removeConversationForm = React.useCallback( + () =>

Are you sure you want to remove this conversation from your list?

, + [], + ); - const removeConversationButton = (conversationId: string, participantId: string, onRemove?: () => void) => ( - - - + const removeConversationButton = React.useCallback( + (conversationId: string, participantId: string, onRemove?: () => void) => ( + + + + ), + [handleRemove, submitted], ); return { diff --git a/workbench-app/src/components/Conversations/ConversationRename.tsx b/workbench-app/src/components/Conversations/ConversationRename.tsx index 9e18d7da..46151a58 100644 --- a/workbench-app/src/components/Conversations/ConversationRename.tsx +++ b/workbench-app/src/components/Conversations/ConversationRename.tsx @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. -import { Button, DialogTrigger, Field, Input } from '@fluentui/react-components'; +import { Button, DialogOpenChangeData, DialogOpenChangeEvent, Field, Input } from '@fluentui/react-components'; import { EditRegular } from '@fluentui/react-icons'; import React from 'react'; +import { useNotify } from '../../libs/useNotify'; +import { Utility } from '../../libs/Utility'; import { useUpdateConversationMutation } from '../../services/workbench'; import { CommandButton } from '../App/CommandButton'; import { DialogControl } from '../App/DialogControl'; @@ -12,39 +14,48 @@ export const useConversationRenameControls = (id: string, value: string) => { const [newTitle, setNewTitle] = React.useState(value); const [submitted, setSubmitted] = React.useState(false); - const handleRename = async (onRename?: (id: string, value: string) => Promise) => { - if (submitted) { - return; - } - setSubmitted(true); - await updateConversation({ id, title: newTitle }); - - if (onRename) { - await onRename(id, newTitle); - } - - setSubmitted(false); - }; + const handleRename = React.useCallback( + async (onRename?: (id: string, value: string) => Promise, onError?: (error: Error) => void) => { + try { + await Utility.withStatus(setSubmitted, async () => { + await updateConversation({ id, title: newTitle }); + await onRename?.(id, newTitle); + }); + } catch (error) { + onError?.(error as Error); + } + }, + [id, newTitle, updateConversation], + ); - const renameConversationForm = (onRename?: (id: string, value: string) => Promise) => ( -
{ - event.preventDefault(); - handleRename(onRename); - }} - > - - setNewTitle(data.value)} /> - -
+ const renameConversationForm = React.useCallback( + (onRename?: (id: string, value: string) => Promise) => ( +
{ + event.preventDefault(); + handleRename(onRename); + }} + > + + setNewTitle(data.value)} /> + +
+ ), + [handleRename, newTitle, submitted], ); - const renameConversationButton = (onRename?: (id: string, value: string) => Promise) => ( - - - + ), + [handleRename, newTitle, submitted], ); return { @@ -54,40 +65,53 @@ export const useConversationRenameControls = (id: string, value: string) => { }; interface ConversationRenameDialogProps { - id: string; + conversationId: string; value: string; onRename: (id: string, value: string) => Promise; - onCancel: () => void; + open?: boolean; + onOpenChange: (event: DialogOpenChangeEvent, data: DialogOpenChangeData) => void; } export const ConversationRenameDialog: React.FC = (props) => { - const { id, value, onRename, onCancel } = props; - const { renameConversationForm, renameConversationButton } = useConversationRenameControls(id, value); + const { conversationId, value, onRename, open, onOpenChange } = props; + const { renameConversationForm, renameConversationButton } = useConversationRenameControls(conversationId, value); + const { notifyWarning } = useNotify(); + + const handleError = React.useCallback( + (error: Error) => { + notifyWarning({ + id: 'error', + title: 'Rename conversation failed', + message: error.message, + }); + }, + [notifyWarning], + ); return ( ); }; interface ConversationRenameProps { + conversationId: string; disabled?: boolean; - id: string; value: string; - onRename?: (id: string, value: string) => Promise; + onRename?: (conversationId: string, value: string) => Promise; iconOnly?: boolean; asToolbarButton?: boolean; } export const ConversationRename: React.FC = (props) => { - const { id, value, onRename, disabled, iconOnly, asToolbarButton } = props; - const { renameConversationForm, renameConversationButton } = useConversationRenameControls(id, value); + const { conversationId, value, onRename, disabled, iconOnly, asToolbarButton } = props; + const { renameConversationForm, renameConversationButton } = useConversationRenameControls(conversationId, value); return ( { return { - shareConversationForm: (conversation: Conversation) => ( -

- -

+ shareConversationForm: React.useCallback( + (conversation: Conversation) => ( +

+ +

+ ), + [], ), }; }; diff --git a/workbench-app/src/components/Conversations/ConversationShareCreate.tsx b/workbench-app/src/components/Conversations/ConversationShareCreate.tsx index ddd7e204..36195ab8 100644 --- a/workbench-app/src/components/Conversations/ConversationShareCreate.tsx +++ b/workbench-app/src/components/Conversations/ConversationShareCreate.tsx @@ -46,32 +46,33 @@ export const ConversationShareCreate: React.FC = ( const conversationUtility = useConversationUtility(); const handleCreate = React.useCallback(async () => { + if (submitted) { + return; + } setSubmitted(true); - // Get the permission and metadata for the share type. - const { permission, metadata } = conversationUtility.getShareTypeMetadata(shareType, linkToMessageId); - // Create the share. - const conversationShare = await createShare({ - conversationId: conversation!.id, - label: shareLabel, - conversationPermission: permission, - metadata: metadata, - }).unwrap(); - onCreated?.(conversationShare); - setSubmitted(false); - }, [ - conversationUtility, - shareType, - linkToMessageId, - createShare, - conversation, - shareLabel, - setSubmitted, - onCreated, - ]); - const handleFocus = (event: React.FocusEvent) => event.target.select(); + try { + // Get the permission and metadata for the share type. + const { permission, metadata } = conversationUtility.getShareTypeMetadata(shareType, linkToMessageId); + // Create the share. + const conversationShare = await createShare({ + conversationId: conversation!.id, + label: shareLabel, + conversationPermission: permission, + metadata: metadata, + }).unwrap(); + onCreated?.(conversationShare); + } finally { + setSubmitted(false); + } + }, [submitted, conversationUtility, shareType, linkToMessageId, createShare, conversation, shareLabel, onCreated]); - const createTitle = linkToMessageId ? 'Create a new message share link' : 'Create a new share link'; + const handleFocus = React.useCallback((event: React.FocusEvent) => event.target.select(), []); + + const createTitle = React.useMemo( + () => (linkToMessageId ? 'Create a new message share link' : 'Create a new share link'), + [linkToMessageId], + ); const handleOpenChange = React.useCallback( (_: DialogOpenChangeEvent, data: DialogOpenChangeData) => { diff --git a/workbench-app/src/components/Conversations/ConversationTranscript.tsx b/workbench-app/src/components/Conversations/ConversationTranscript.tsx index 683c18bd..75daee5a 100644 --- a/workbench-app/src/components/Conversations/ConversationTranscript.tsx +++ b/workbench-app/src/components/Conversations/ConversationTranscript.tsx @@ -17,16 +17,26 @@ interface ConversationTranscriptProps { export const ConversationTranscript: React.FC = (props) => { const { conversation, participants, iconOnly, asToolbarButton } = props; const workbenchService = useWorkbenchService(); + const [submitted, setSubmitted] = React.useState(false); - const getTranscript = async () => { - const { blob, filename } = await workbenchService.exportTranscriptAsync(conversation, participants); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }; + const getTranscript = React.useCallback(async () => { + if (submitted) { + return; + } + setSubmitted(true); + + try { + const { blob, filename } = await workbenchService.exportTranscriptAsync(conversation, participants); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } finally { + setSubmitted(false); + } + }, [submitted, workbenchService, conversation, participants]); return (
@@ -35,7 +45,8 @@ export const ConversationTranscript: React.FC = (pr icon={} iconOnly={iconOnly} asToolbarButton={asToolbarButton} - label="Download" + disabled={submitted} + label={submitted ? 'Downloading...' : 'Download'} onClick={getTranscript} />
diff --git a/workbench-app/src/components/Conversations/FileItem.tsx b/workbench-app/src/components/Conversations/FileItem.tsx index f9c76254..0f0b4b18 100644 --- a/workbench-app/src/components/Conversations/FileItem.tsx +++ b/workbench-app/src/components/Conversations/FileItem.tsx @@ -46,8 +46,12 @@ export const FileItem: React.FC = (props) => { const classes = useClasses(); const workbenchService = useWorkbenchService(); const [deleteConversationFile] = useDeleteConversationFileMutation(); + const [submitted, setSubmitted] = React.useState(false); - const time = Utility.toFormattedDateString(conversationFile.updated, 'M/D/YYYY h:mm A'); + const time = React.useMemo( + () => Utility.toFormattedDateString(conversationFile.updated, 'M/D/YYYY h:mm A'), + [conversationFile.updated], + ); const sizeToDisplay = (size: number) => { if (size < 1024) { @@ -59,45 +63,63 @@ export const FileItem: React.FC = (props) => { } }; - const handleDelete = async () => { - await deleteConversationFile({ conversationId: conversation.id, filename: conversationFile.name }); - }; + const handleDelete = React.useCallback(async () => { + if (submitted) { + return; + } + setSubmitted(true); - const handleDownload = async () => { - const response: Response = await workbenchService.downloadConversationFileAsync( - conversation.id, - conversationFile, - ); + try { + await deleteConversationFile({ conversationId: conversation.id, filename: conversationFile.name }); + } finally { + setSubmitted(false); + } + }, [conversation.id, conversationFile.name, deleteConversationFile, submitted]); - if (!response.ok || !response.body) { - throw new Error('Failed to fetch file'); + const handleDownload = React.useCallback(async () => { + if (submitted) { + return; } + setSubmitted(true); - // Create a file stream using StreamSaver - const fileStream = StreamSaver.createWriteStream(conversationFile.name); + try { + const response: Response = await workbenchService.downloadConversationFileAsync( + conversation.id, + conversationFile, + ); - const readableStream = response.body; + if (!response.ok || !response.body) { + throw new Error('Failed to fetch file'); + } - // Check if the browser supports pipeTo (most modern browsers do) - if (readableStream.pipeTo) { - await readableStream.pipeTo(fileStream); - } else { - // Fallback for browsers that don't support pipeTo - const reader = readableStream.getReader(); - const writer = fileStream.getWriter(); - - const pump = () => - reader.read().then(({ done, value }) => { - if (done) { - writer.close(); - return; - } - writer.write(value).then(pump); - }); - - await pump(); + // Create a file stream using StreamSaver + const fileStream = StreamSaver.createWriteStream(conversationFile.name); + + const readableStream = response.body; + + // Check if the browser supports pipeTo (most modern browsers do) + if (readableStream.pipeTo) { + await readableStream.pipeTo(fileStream); + } else { + // Fallback for browsers that don't support pipeTo + const reader = readableStream.getReader(); + const writer = fileStream.getWriter(); + + const pump = () => + reader.read().then(({ done, value }) => { + if (done) { + writer.close(); + return; + } + writer.write(value).then(pump); + }); + + await pump(); + } + } finally { + setSubmitted(false); } - }; + }, [conversation.id, conversationFile, workbenchService, submitted]); return ( @@ -122,6 +144,7 @@ export const FileItem: React.FC = (props) => { description="Download file from conversation" icon={} onClick={handleDownload} + disabled={submitted} /> = (props) => { closeLabel: 'Cancel', additionalActions: [ - , ], diff --git a/workbench-app/src/components/Conversations/InteractHistory.tsx b/workbench-app/src/components/Conversations/InteractHistory.tsx index 69316a56..391b4357 100644 --- a/workbench-app/src/components/Conversations/InteractHistory.tsx +++ b/workbench-app/src/components/Conversations/InteractHistory.tsx @@ -57,7 +57,7 @@ export const InteractHistory: React.FC = (props) => { const { conversation, messages, participants, readOnly, className, onRewindToBefore } = props; const classes = useClasses(); const { hash } = useLocation(); - const { setLastRead } = useConversationUtility(); + const { debouncedSetLastRead } = useConversationUtility(); const [scrollToIndex, setScrollToIndex] = React.useState(); const [items, setItems] = React.useState([]); const [isAtBottom, setIsAtBottom] = React.useState(true); @@ -65,8 +65,8 @@ export const InteractHistory: React.FC = (props) => { // handler for when a message is read const handleOnRead = React.useCallback( // update the last read timestamp for the conversation - async (message: ConversationMessage) => await setLastRead(conversation, message.timestamp), - [setLastRead, conversation], + async (message: ConversationMessage) => await debouncedSetLastRead(conversation, message.timestamp), + [debouncedSetLastRead, conversation], ); // create a ref for the virtuoso component for using its methods directly diff --git a/workbench-app/src/components/Conversations/MessageDelete.tsx b/workbench-app/src/components/Conversations/MessageDelete.tsx index 9a639a94..1dd56b4d 100644 --- a/workbench-app/src/components/Conversations/MessageDelete.tsx +++ b/workbench-app/src/components/Conversations/MessageDelete.tsx @@ -17,12 +17,22 @@ interface MessageDeleteProps { export const MessageDelete: React.FC = (props) => { const { conversationId, message, onDelete, disabled } = props; const [deleteMessage] = useDeleteConversationMessageMutation(); + const [submitted, setSubmitted] = React.useState(false); const handleDelete = React.useCallback(async () => { - await deleteMessage({ conversationId, messageId: message.id }); + if (submitted) { + return; + } + setSubmitted(true); - onDelete?.(message); - }, [conversationId, deleteMessage, message, onDelete]); + try { + await deleteMessage({ conversationId, messageId: message.id }); + + onDelete?.(message); + } finally { + setSubmitted(false); + } + }, [conversationId, deleteMessage, message, onDelete, submitted]); return ( = (props) => { closeLabel: 'Cancel', additionalActions: [ - , ], diff --git a/workbench-app/src/components/Conversations/MyConversations.tsx b/workbench-app/src/components/Conversations/MyConversations.tsx index 52301f1e..af4cbe9e 100644 --- a/workbench-app/src/components/Conversations/MyConversations.tsx +++ b/workbench-app/src/components/Conversations/MyConversations.tsx @@ -56,12 +56,12 @@ export const MyConversations: React.FC = (props) => { <> - + = (props) => { const { shares, hideInstruction, title, conversation } = props; const [newOpen, setNewOpen] = React.useState(Boolean(conversation && shares.length === 0)); - const [conversationShareForDetails, setConversationShareForDetails] = React.useState( - undefined, - ); + const [conversationShareForDetails, setConversationShareForDetails] = React.useState(); const conversationUtility = useConversationUtility(); const createTitle = 'Create a new share link'; diff --git a/workbench-app/src/components/Conversations/RewindConversation.tsx b/workbench-app/src/components/Conversations/RewindConversation.tsx index 66aeb232..8801952a 100644 --- a/workbench-app/src/components/Conversations/RewindConversation.tsx +++ b/workbench-app/src/components/Conversations/RewindConversation.tsx @@ -15,9 +15,23 @@ interface RewindConversationProps { export const RewindConversation: React.FC = (props) => { const { onRewind, disabled } = props; + const [submitted, setSubmitted] = React.useState(false); - const handleRewind = React.useCallback(async () => onRewind?.(false), [onRewind]); - const handleRewindWithRedo = React.useCallback(async () => onRewind?.(true), [onRewind]); + const handleRewind = React.useCallback( + async (redo: boolean = false) => { + if (submitted) { + return; + } + setSubmitted(true); + + try { + onRewind?.(redo); + } finally { + setSubmitted(false); + } + }, + [onRewind, submitted], + ); return ( = (props) => closeLabel: 'Cancel', additionalActions: [ - , - + , ], }} diff --git a/workbench-app/src/components/Conversations/ShareRemove.tsx b/workbench-app/src/components/Conversations/ShareRemove.tsx index c2850a9b..5cd9a425 100644 --- a/workbench-app/src/components/Conversations/ShareRemove.tsx +++ b/workbench-app/src/components/Conversations/ShareRemove.tsx @@ -20,10 +20,18 @@ export const ShareRemove: React.FC = (props) => { const [isDeleting, setIsDeleting] = React.useState(false); const handleDelete = React.useCallback(async () => { + if (isDeleting) { + return; + } setIsDeleting(true); - await deleteShare(share.id); - onDelete?.(); - }, [share.id, onDelete, deleteShare, setIsDeleting]); + + try { + await deleteShare(share.id); + onDelete?.(); + } finally { + setIsDeleting(false); + } + }, [isDeleting, deleteShare, share.id, onDelete]); return ( = (props) => { closeLabel: 'Cancel', additionalActions: [ - , ], diff --git a/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx b/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx index 610c504d..724f6d52 100644 --- a/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx @@ -18,6 +18,8 @@ import { import { ArrowDownloadRegular, EditRegular, + GlassesOffRegular, + GlassesRegular, MoreHorizontalRegular, Pin12Regular, PinOffRegular, @@ -28,7 +30,6 @@ import { } from '@fluentui/react-icons'; import React from 'react'; import { useConversationUtility } from '../../../libs/useConversationUtility'; -import { useExportUtility } from '../../../libs/useExportUtility'; import { Utility } from '../../../libs/Utility'; import { Conversation } from '../../../models/Conversation'; import { ConversationParticipant } from '../../../models/ConversationParticipant'; @@ -136,9 +137,16 @@ export const ConversationItem: React.FC = (props) => { onSelectForActions, } = props; const classes = useClasses(); - const { getOwnerParticipant, wasSharedWithMe, hasUnreadMessages, isPinned, setPinned } = useConversationUtility(); + const { + getOwnerParticipant, + wasSharedWithMe, + hasUnreadMessages, + isPinned, + setPinned, + markAllAsRead, + markAllAsUnread, + } = useConversationUtility(); const localUserId = useAppSelector((state) => state.localUser.id); - const { exportConversation } = useExportUtility(); const [isHovered, setIsHovered] = React.useState(false); const showActions = isHovered || showSelectForActions; @@ -180,6 +188,15 @@ export const ConversationItem: React.FC = (props) => { > {isPinned(conversation) ? 'Unpin' : 'Pin'} + : } + onClick={(event) => { + const hasUnread = hasUnreadMessages(conversation); + handleMenuItemClick(event, hasUnread ? markAllAsRead : markAllAsUnread); + }} + > + {hasUnreadMessages(conversation) ? 'Mark read' : 'Mark unread'} + {onRename && ( } @@ -191,10 +208,7 @@ export const ConversationItem: React.FC = (props) => { )} } - onClick={async (event) => { - await exportConversation(conversation.id); - handleMenuItemClick(event, onExport); - }} + onClick={async (event) => handleMenuItemClick(event, onExport)} > Export @@ -242,9 +256,11 @@ export const ConversationItem: React.FC = (props) => { classes.moreButton, classes.selectCheckbox, conversation, - exportConversation, handleMenuItemClick, + hasUnreadMessages, isPinned, + markAllAsRead, + markAllAsUnread, onDuplicate, onExport, onPinned, diff --git a/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx b/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx index 919a3960..85b67475 100644 --- a/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx @@ -13,6 +13,7 @@ import { useGetConversationsQuery } from '../../../services/workbench'; import { Loading } from '../../App/Loading'; import { PresenceMotionList } from '../../App/PresenceMotionList'; import { ConversationDuplicateDialog } from '../../Conversations/ConversationDuplicate'; +import { ConversationExportWithStatusDialog } from '../../Conversations/ConversationExport'; import { ConversationRemoveDialog } from '../../Conversations/ConversationRemove'; import { ConversationRenameDialog } from '../../Conversations/ConversationRename'; import { ConversationShareDialog } from '../../Conversations/ConversationShare'; @@ -46,6 +47,7 @@ export const ConversationList: React.FC = () => { const [renameConversation, setRenameConversation] = React.useState(); const [duplicateConversation, setDuplicateConversation] = React.useState(); + const [exportConversation, setExportConversation] = React.useState(); const [shareConversation, setShareConversation] = React.useState(); const [removeConversation, setRemoveConversation] = React.useState(); const [selectedForActions, setSelectedForActions] = React.useState(new Set()); @@ -123,24 +125,34 @@ export const ConversationList: React.FC = () => { [handleUpdateSelectedForActions], ); + const handleDuplicateConversationComplete = React.useCallback( + async (id: string) => { + navigateToConversation(id); + setDuplicateConversation(undefined); + }, + [navigateToConversation], + ); + const actionHelpers = React.useMemo( () => ( <> - {renameConversation && ( - setRenameConversation(undefined)} - onCancel={() => setRenameConversation(undefined)} - /> - )} - {duplicateConversation && ( - navigateToConversation(id)} - onCancel={() => setDuplicateConversation(undefined)} - /> - )} + setRenameConversation(undefined)} + onRename={async () => setRenameConversation(undefined)} + /> + setDuplicateConversation(undefined)} + onDuplicate={handleDuplicateConversationComplete} + /> + setExportConversation(undefined)} + /> {shareConversation && ( { [ renameConversation, duplicateConversation, + handleDuplicateConversationComplete, + exportConversation, shareConversation, removeConversation, - navigateToConversation, localUserId, activeConversationId, + navigateToConversation, ], ); @@ -207,6 +221,7 @@ export const ConversationList: React.FC = () => { onSelectForActions={handleItemSelectForActions} onRename={setRenameConversation} onDuplicate={setDuplicateConversation} + onExport={setExportConversation} onShare={setShareConversation} onRemove={setRemoveConversation} /> diff --git a/workbench-app/src/components/FrontDoor/Controls/ConversationListOptions.tsx b/workbench-app/src/components/FrontDoor/Controls/ConversationListOptions.tsx index 9848bbca..7e8ff118 100644 --- a/workbench-app/src/components/FrontDoor/Controls/ConversationListOptions.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/ConversationListOptions.tsx @@ -26,8 +26,8 @@ import { CheckboxUncheckedRegular, DismissRegular, FilterRegular, - MailReadRegular, - MailUnreadRegular, + GlassesOffRegular, + GlassesRegular, PinOffRegular, PinRegular, PlugDisconnectedRegular, @@ -95,7 +95,7 @@ export const ConversationListOptions: React.FC = ( const { conversations, selectedForActions, onSelectedForActionsChanged, onDisplayedConversationsChanged } = props; const classes = useClasses(); const localUserId = useAppSelector((state) => state.localUser.id); - const { hasUnreadMessages, markAllAsRead, markAsUnread, isPinned, setPinned } = useConversationUtility(); + const { hasUnreadMessages, markAllAsRead, markAllAsUnread, isPinned, setPinned } = useConversationUtility(); const [filterString, setFilterString] = React.useState(''); const [displayFilter, setDisplayFilter] = React.useState(''); const [sortByName, setSortByName] = React.useState(false); @@ -411,9 +411,9 @@ export const ConversationListOptions: React.FC = ( }, [getSelectedConversations, markAllAsRead, onSelectedForActionsChanged]); const handleMarkAsUnreadForSelected = React.useCallback(async () => { - await markAsUnread(getSelectedConversations()); + await markAllAsUnread(getSelectedConversations()); onSelectedForActionsChanged(new Set()); - }, [getSelectedConversations, markAsUnread, onSelectedForActionsChanged]); + }, [getSelectedConversations, markAllAsUnread, onSelectedForActionsChanged]); const handleRemoveForSelected = React.useCallback(async () => { // TODO: implement remove conversation @@ -438,7 +438,7 @@ export const ConversationListOptions: React.FC = ( , , ]} /> diff --git a/workbench-app/src/components/Workflows/WorkflowCreate.tsx b/workbench-app/src/components/Workflows/WorkflowCreate.tsx index 5292c0ae..d2e61ec8 100644 --- a/workbench-app/src/components/Workflows/WorkflowCreate.tsx +++ b/workbench-app/src/components/Workflows/WorkflowCreate.tsx @@ -87,6 +87,8 @@ export const WorkflowCreate: React.FC = (props) => { }).unwrap(); onOpenChange?.(false); onCreate?.(workflowDefinition); + + setSubmitted(false); }; React.useEffect(() => { diff --git a/workbench-app/src/components/Workflows/WorkflowDesigner/AssistantDefinitionCreate.tsx b/workbench-app/src/components/Workflows/WorkflowDesigner/AssistantDefinitionCreate.tsx index b36a7adc..3d6d9c04 100644 --- a/workbench-app/src/components/Workflows/WorkflowDesigner/AssistantDefinitionCreate.tsx +++ b/workbench-app/src/components/Workflows/WorkflowDesigner/AssistantDefinitionCreate.tsx @@ -55,6 +55,7 @@ export const AssistantDefinitionCreate: React.FC = (props) const classes = useClasses(); const [name, setName] = React.useState(''); const [assistantServiceId, setAssistantServiceId] = React.useState(''); + const [submitted, setSubmitted] = React.useState(false); const { data: assistantServices, @@ -67,14 +68,23 @@ export const AssistantDefinitionCreate: React.FC = (props) throw new Error(`Error loading assistant services: ${errorMessage}`); } - const handleSave = async () => { - onOpenChange?.(false); - onCreate?.({ - id: generateUuid(), - name, - assistantServiceId, - }); - }; + const handleSave = React.useCallback(async () => { + if (submitted) { + return; + } + setSubmitted(true); + + try { + onOpenChange?.(false); + onCreate?.({ + id: generateUuid(), + name, + assistantServiceId, + }); + } finally { + setSubmitted(false); + } + }, [assistantServiceId, name, onCreate, onOpenChange, submitted]); const handleOpenChange = React.useCallback( (_event: DialogOpenChangeEvent, data: DialogOpenChangeData) => { @@ -189,8 +199,12 @@ export const AssistantDefinitionCreate: React.FC = (props) closeLabel="Cancel" additionalActions={[ - , ]} diff --git a/workbench-app/src/components/Workflows/WorkflowDesigner/ConversationDefinitionCreate.tsx b/workbench-app/src/components/Workflows/WorkflowDesigner/ConversationDefinitionCreate.tsx index e2c6487e..5c73436b 100644 --- a/workbench-app/src/components/Workflows/WorkflowDesigner/ConversationDefinitionCreate.tsx +++ b/workbench-app/src/components/Workflows/WorkflowDesigner/ConversationDefinitionCreate.tsx @@ -2,6 +2,7 @@ import { generateUuid } from '@azure/ms-rest-js'; import { + Button, DialogOpenChangeData, DialogOpenChangeEvent, Field, @@ -31,14 +32,24 @@ export const ConversationDefinitionCreate: React.FC { - onOpenChange?.(false); - onCreate?.({ - id: generateUuid(), - title, - }); - }; + const handleSave = React.useCallback(() => { + if (submitted) { + return; + } + setSubmitted(true); + + try { + onOpenChange?.(false); + onCreate?.({ + id: generateUuid(), + title, + }); + } finally { + setSubmitted(false); + } + }, [onCreate, onOpenChange, submitted, title]); React.useEffect(() => { if (!open) { @@ -80,6 +91,11 @@ export const ConversationDefinitionCreate: React.FC } closeLabel="Cancel" + additionalActions={[ + , + ]} /> ); }; diff --git a/workbench-app/src/components/Workflows/WorkflowRunCreate.tsx b/workbench-app/src/components/Workflows/WorkflowRunCreate.tsx index 5dcbff8d..35c78c59 100644 --- a/workbench-app/src/components/Workflows/WorkflowRunCreate.tsx +++ b/workbench-app/src/components/Workflows/WorkflowRunCreate.tsx @@ -38,20 +38,25 @@ export const WorkflowRunCreate: React.FC = (props) => { const [title, setTitle] = React.useState(''); const [submitted, setSubmitted] = React.useState(false); - const handleSave = async () => { + const handleSave = React.useCallback(async () => { if (submitted) { return; } setSubmitted(true); - const workflowRun = await createWorkflowRun({ - title, - workflowDefinitionId, - }).unwrap(); - await refetchWorkflowRuns(); - onOpenChange?.(false); - onCreate?.(workflowRun); - }; + try { + const workflowRun = await createWorkflowRun({ + title, + workflowDefinitionId, + }).unwrap(); + + await refetchWorkflowRuns(); + onOpenChange?.(false); + onCreate?.(workflowRun); + } finally { + setSubmitted(false); + } + }, [createWorkflowRun, onCreate, onOpenChange, refetchWorkflowRuns, submitted, title, workflowDefinitionId]); React.useEffect(() => { if (!open) { diff --git a/workbench-app/src/libs/Utility.ts b/workbench-app/src/libs/Utility.ts index 0a0d1a39..6eb4a9c7 100644 --- a/workbench-app/src/libs/Utility.ts +++ b/workbench-app/src/libs/Utility.ts @@ -64,15 +64,6 @@ const deepDiff = (obj1: ObjectLiteral, obj2: ObjectLiteral, parentKey = ''): Obj type ObjectLiteral = { [key: string]: any }; -const debounce = (func: Function, wait: number) => { - let timeout: NodeJS.Timeout; - return function (this: any, ...args: any[]) { - const context = this; - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(context, args), wait); - }; -}; - const toDayJs = (value: string | Date, timezone: string = dayjs.tz.guess()) => { return dayjs.utc(value).tz(timezone); }; @@ -187,7 +178,6 @@ export const Utility = { deepCopy, deepMerge, deepDiff, - debounce, toDate, toSimpleDateString, toFormattedDateString, diff --git a/workbench-app/src/libs/useConversationUtility.ts b/workbench-app/src/libs/useConversationUtility.ts index 1bce7084..f27880f4 100644 --- a/workbench-app/src/libs/useConversationUtility.ts +++ b/workbench-app/src/libs/useConversationUtility.ts @@ -1,3 +1,4 @@ +import dayjs from 'dayjs'; import debug from 'debug'; import React from 'react'; import { useNavigate } from 'react-router-dom'; @@ -6,7 +7,6 @@ import { Conversation } from '../models/Conversation'; import { ConversationShare } from '../models/ConversationShare'; import { useAppSelector } from '../redux/app/hooks'; import { useUpdateConversationMutation } from '../services/workbench'; -import { Utility } from './Utility'; const log = debug(Constants.debug.root).extend('useConversationUtility'); @@ -201,7 +201,7 @@ export const useConversationUtility = () => { return true; } const lastMessageTimestamp = getLastMessageTimestamp(conversation); - return lastMessageTimestamp > lastReadTimestamp; + return dayjs(lastMessageTimestamp).isAfter(lastReadTimestamp); }, [getLastReadTimestamp, getLastMessageTimestamp], ); @@ -212,7 +212,7 @@ export const useConversationUtility = () => { if (!lastReadTimestamp) { return true; } - return messageTimestamp > lastReadTimestamp; + return dayjs(messageTimestamp).isAfter(lastReadTimestamp); }, [getLastReadTimestamp], ); @@ -235,7 +235,7 @@ export const useConversationUtility = () => { [hasUnreadMessages, setAppMetadata, getLastMessageTimestamp], ); - const markAsUnread = React.useCallback( + const markAllAsUnread = React.useCallback( async (conversation: Conversation | Conversation[]) => { const markSingleConversation = async (c: Conversation) => { if (hasUnreadMessages(c)) { @@ -255,19 +255,28 @@ export const useConversationUtility = () => { ); const setLastRead = React.useCallback( - async (conversation: Conversation | Conversation[], messageTimestamp: string) => - Utility.debounce(async () => { - if (Array.isArray(conversation)) { - await Promise.all( - conversation.map((c) => setAppMetadata(c, { lastReadTimestamp: messageTimestamp })), - ); - return; - } - await setAppMetadata(conversation, { lastReadTimestamp: messageTimestamp }); - }, 300), + async (conversation: Conversation | Conversation[], messageTimestamp: string) => { + if (Array.isArray(conversation)) { + await Promise.all(conversation.map((c) => setAppMetadata(c, { lastReadTimestamp: messageTimestamp }))); + return; + } + await setAppMetadata(conversation, { lastReadTimestamp: messageTimestamp }); + }, [setAppMetadata], ); + // Create a debounced version of setLastRead + const timeoutRef = React.useRef(null); + const debouncedSetLastRead = React.useCallback( + (conversation: Conversation | Conversation[], messageTimestamp: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => setLastRead(conversation, messageTimestamp), 300); + }, + [setLastRead], + ); + // endregion // region Pinning @@ -309,8 +318,9 @@ export const useConversationUtility = () => { hasUnreadMessages, isUnread, markAllAsRead, - markAsUnread, + markAllAsUnread, setLastRead, + debouncedSetLastRead, isPinned, setPinned, }; diff --git a/workbench-app/src/routes/Interact.tsx b/workbench-app/src/routes/Interact.tsx index 03c65ecf..b668c0fb 100644 --- a/workbench-app/src/routes/Interact.tsx +++ b/workbench-app/src/routes/Interact.tsx @@ -153,7 +153,7 @@ export const Interact: React.FC = () => { title={