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: [
-
- Duplicate
+
+ {submitted ? 'Duplicating...' : 'Duplicate'}
,
],
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,
- ) => (
-
- duplicateConversations(onDuplicate, onDuplicateError)}>
- Duplicate
+ const duplicateConversationButton = React.useCallback(
+ (onDuplicate?: (conversationId: string) => Promise, onError?: (error: Error) => void) => (
+ duplicateConversation(onDuplicate, onError)}
+ disabled={submitted}
+ >
+ {submitted ? 'Duplicating...' : 'Duplicate'}
-
+ ),
+ [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) => (
-
- handleRemove(conversationId, participantId, onRemove)}>
- Remove
-
-
+ const removeConversationButton = React.useCallback(
+ (conversationId: string, participantId: string, onRemove?: () => void) => (
+
+ handleRemove(conversationId, participantId, onRemove)}
+ disabled={submitted}
+ >
+ {submitted ? 'Removing...' : 'Remove'}
+
+
+ ),
+ [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) => (
-
+ const renameConversationForm = React.useCallback(
+ (onRename?: (id: string, value: string) => Promise) => (
+
+ ),
+ [handleRename, newTitle, submitted],
);
- const renameConversationButton = (onRename?: (id: string, value: string) => Promise) => (
-
- handleRename(onRename)} appearance="primary">
+ const renameConversationButton = React.useCallback(
+ (onRename?: (id: string, value: string) => Promise, onError?: (error: Error) => void) => (
+ handleRename(onRename, onError)}
+ appearance="primary"
+ >
{submitted ? 'Renaming...' : 'Rename'}
-
+ ),
+ [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: [
-
- Delete
+
+ {submitted ? 'Deleting...' : 'Delete'}
,
],
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: [
-
- Delete
+
+ {submitted ? 'Deleting...' : 'Delete'}
,
],
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: [
-
- Rewind
+ handleRewind()} disabled={submitted}>
+ {submitted ? 'Rewinding...' : 'Rewind'}
,
- Rewind with Redo
+ handleRewind(true)} disabled={submitted}>
+ {submitted ? 'Rewinding and redoing...' : 'Rewind with Redo'}
+
,
],
}}
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: [
-
- Delete
+
+ {isDeleting ? 'Deleting...' : 'Delete'}
,
],
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 = (
}
+ icon={}
disabled={!enableBulkActions.read}
onClick={handleMarkAllAsReadForSelected}
/>
@@ -446,7 +446,7 @@ export const ConversationListOptions: React.FC = (
}
+ icon={}
disabled={!enableBulkActions.unread}
onClick={handleMarkAsUnreadForSelected}
/>
diff --git a/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx b/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx
index 1c6c99d2..f2ad67a1 100644
--- a/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx
+++ b/workbench-app/src/components/FrontDoor/Controls/NewConversationButton.tsx
@@ -85,7 +85,7 @@ export const NewConversationButton: React.FC = () => {
,
- Create
+ {submitted ? 'Creating...' : 'Create'}
,
]}
/>
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={[
-
- Save
+
+ {submitted ? 'Saving...' : 'Save'}
,
]}
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={[
+
+ {submitted ? 'Saving...' : 'Save'}
+ ,
+ ]}
/>
);
};
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={