Extract ChatExecuteMode

This commit is contained in:
Enrico Ros
2024-07-02 23:51:24 -07:00
parent 52e6ef436f
commit bef1c0c5fc
8 changed files with 222 additions and 182 deletions
+6 -17
View File
@@ -44,6 +44,8 @@ import { ChatPageMenuItems } from './components/layout-menu/ChatPageMenuItems';
import { Composer } from './components/composer/Composer';
import { usePanesManager } from './components/panes/usePanesManager';
import type { ChatExecuteMode } from './execute-mode/execute-mode.types';
import { _handleExecute } from './editors/_handleExecute';
import { gcChatImageAssets } from './editors/image-generate';
@@ -52,19 +54,6 @@ import { gcChatImageAssets } from './editors/image-generate';
export const CHAT_NOVEL_TITLE = 'Chat';
/**
* Mode: how to treat the input from the Composer
*/
export type ChatModeId =
| 'append-user'
| 'beam-content'
| 'generate-content'
| 'generate-image'
| 'generate-text-v1'
| 'react-content'
;
export interface AppChatIntent {
initialConversationId: string | null;
}
@@ -206,8 +195,8 @@ export function AppChat() {
// Execution
const handleExecuteAndOutcome = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, callerNameDebug: string) => {
const outcome = await _handleExecute(chatModeId, conversationId, callerNameDebug);
const handleExecuteAndOutcome = React.useCallback(async (chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, callerNameDebug: string) => {
const outcome = await _handleExecute(chatExecuteMode, conversationId, callerNameDebug);
if (outcome === 'err-no-chatllm')
openModelsSetup();
else if (outcome === 'err-t2i-unconfigured')
@@ -221,7 +210,7 @@ export function AppChat() {
return outcome === true;
}, [openModelsSetup, openPreferencesTab]);
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatModeId: ChatModeId, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => {
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => {
// [multicast] send the message to all the panes
const uniqueConversationIds = willMulticast
@@ -245,7 +234,7 @@ export function AppChat() {
ConversationsManager.getHandler(conversation.id).messageAppend(userMessage); // [chat] append user message in each conversation
// fire/forget
void handleExecuteAndOutcome(chatModeId /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*'
void handleExecuteAndOutcome(chatExecuteMode /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*'
}
return true;
@@ -1,112 +0,0 @@
import * as React from 'react';
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import type { ChatModeId } from '../../AppChat';
export function chatModeCanAttach(chatModeId: ChatModeId) {
return !!ChatModeItems[chatModeId]?.canAttach;
}
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
'generate-content': {
label: 'Chat',
description: 'Persona replies',
canAttach: true,
},
'generate-text-v1': {
label: 'Chat (Stable)',
description: 'Model replies (stable)',
canAttach: true,
},
'beam-content': {
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
description: 'Combine multiple models', // Smarter: combine...
shortcut: 'Ctrl + Enter',
canAttach: true,
hideOnDesktop: true,
},
'append-user': {
label: 'Write',
description: 'Append a message',
shortcut: 'Alt + Enter',
canAttach: true,
},
'generate-image': {
label: 'Draw',
description: 'AI Image Generation',
requiresTTI: true,
},
'react-content': {
label: 'Reason + Act', // · α
description: 'Answer questions in multiple steps',
},
};
interface ChatModeDescription {
label: string;
description: string | React.JSX.Element;
canAttach?: boolean;
highlight?: boolean;
shortcut?: string;
hideOnDesktop?: boolean;
requiresTTI?: boolean;
}
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
if (shortcut === 'ENTER')
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
return shortcut;
}
export function ChatModeMenu(props: {
isMobile: boolean,
anchorEl: HTMLAnchorElement | null,
onClose: () => void,
chatModeId: ChatModeId,
onSetChatModeId: (chatMode: ChatModeId) => void,
capabilityHasTTI: boolean,
}) {
// external state
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
return (
<CloseableMenu
placement='top-end'
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/* ChatMode items */}
{Object.entries(ChatModeItems)
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio color={data.highlight ? 'success' : undefined} checked={key === props.chatModeId} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
</Box>
{(key === props.chatModeId || !!data.shortcut) && (
<KeyStroke combo={platformAwareKeystrokes(fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline))} />
)}
</Box>
</MenuItem>)}
</CloseableMenu>
);
}
+28 -48
View File
@@ -15,7 +15,6 @@ import SendIcon from '@mui/icons-material/Send';
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
import TelegramIcon from '@mui/icons-material/Telegram';
import type { ChatModeId } from '../../AppChat';
import { useChatMicTimeoutMsValue } from '../../store-app-chat';
import type { DLLM } from '~/modules/llms/store-llms';
@@ -56,6 +55,9 @@ import { LLMAttachmentDraftsAction, LLMAttachmentsList } from './llmattachments/
import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts';
import { useLLMAttachmentDrafts } from './llmattachments/useLLMAttachmentDrafts';
import type { ChatExecuteMode } from '../../execute-mode/execute-mode.types';
import { chatExecuteModeCanAttach, useChatExecuteMode } from '../../execute-mode/useChatExecuteMode';
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
@@ -69,7 +71,6 @@ import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
import { ReplyToBubble } from '../message/ReplyToBubble';
import { TokenBadgeMemo } from './TokenBadge';
import { TokenProgressbarMemo } from './TokenProgressbar';
import { chatModeCanAttach, ChatModeMenu } from './ChatModeMenu';
import { useComposerStartupText } from './store-composer';
@@ -103,19 +104,19 @@ export function Composer(props: {
capabilityHasT2I: boolean;
isMulticast: boolean | null;
isDeveloperMode: boolean;
onAction: (conversationId: DConversationId, chatModeId: ChatModeId, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata) => boolean;
onAction: (conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata) => boolean;
onTextImagine: (conversationId: DConversationId, text: string) => void;
setIsMulticast: (on: boolean) => void;
sx?: SxProps;
}) {
// state
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-content');
const [composeText, debouncedText, setComposeText] = useDebouncer('', 300, 1200, true);
const [micContinuation, setMicContinuation] = React.useState(false);
const [speechInterimResult, setSpeechInterimResult] = React.useState<SpeechResult | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
const { chatExecuteMode, chatExecuteMenuShown, showChatExecuteMenu, chatExecuteMenuComponent } =
useChatExecuteMode(props.capabilityHasT2I, !!props.isMobile);
// external state
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
@@ -148,7 +149,7 @@ export function Composer(props: {
// composer-overlay: for the reply-to state, comes from the conversation overlay
const { replyToGenerateText } = useChatOverlayStore(conversationOverlayStore, useShallow(store => ({
replyToGenerateText: (chatModeId === 'generate-content' || chatModeId === 'generate-text-v1') ? store.replyToText?.trim() || null : null,
replyToGenerateText: (chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1') ? store.replyToText?.trim() || null : null,
})));
// don't load URLs if the user is typing a command or there's no capability
@@ -172,7 +173,7 @@ export function Composer(props: {
const isDesktop = !props.isMobile;
const noConversation = !targetConversationId;
const noLLM = !props.chatLLM;
const showLLMAttachments = chatModeCanAttach(chatModeId);
const showLLMAttachments = chatExecuteModeCanAttach(chatExecuteMode);
// tokens derived state
@@ -221,11 +222,12 @@ export function Composer(props: {
handleReplyToClear();
}, [attachmentsRemoveAll, handleReplyToClear, setComposeText]);
const handleSendAction = React.useCallback(async (_chatModeId: ChatModeId, composerText: string): Promise<boolean> => {
const handleSendAction = React.useCallback(async (_chatExecuteMode: ChatExecuteMode, composerText: string): Promise<boolean> => {
if (!isValidConversation(targetConversationId)) return false;
// validate some chat mode inputs
const isDraw = _chatModeId === 'generate-image';
const isDraw = _chatExecuteMode === 'generate-image';
const isBlank = !composerText.trim();
if (isDraw && isBlank)
return false;
@@ -235,7 +237,7 @@ export function Composer(props: {
if (composerText)
fragments.push(createTextContentFragment(composerText));
const canAttach = chatModeCanAttach(_chatModeId);
const canAttach = chatExecuteModeCanAttach(_chatExecuteMode);
if (canAttach) {
const attachmentFragments = await attachmentsTakeAllFragments('global', 'app-chat');
fragments.push(...attachmentFragments);
@@ -248,15 +250,16 @@ export function Composer(props: {
// send the message - NOTE: if successful, the ownership of the fragments is transferred to the receiver, so we just clear them
const metadata = replyToGenerateText ? { inReplyToText: replyToGenerateText } : undefined;
const enqueued = onAction(targetConversationId, _chatModeId, fragments, metadata);
const enqueued = onAction(targetConversationId, _chatExecuteMode, fragments, metadata);
if (enqueued)
handleClear();
return enqueued;
}, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]);
const handleSendClicked = React.useCallback(async () => {
await handleSendAction(chatModeId, composeText); // 'chat/write/...' button
}, [chatModeId, composeText, handleSendAction]);
await handleSendAction(chatExecuteMode, composeText); // 'chat/write/...' button
}, [chatExecuteMode, composeText, handleSendAction]);
const handleSendTextBeamClicked = React.useCallback(async () => {
await handleSendAction('beam-content', composeText); // 'beam' button
@@ -284,22 +287,6 @@ export function Composer(props: {
}, [composeText, onTextImagine, setComposeText, targetConversationId]);
// Mode menu
const handleModeSelectorHide = React.useCallback(() => {
setChatModeMenuAnchor(null);
}, []);
const handleModeSelectorShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
}, []);
const handleModeChange = React.useCallback((_chatModeId: ChatModeId) => {
handleModeSelectorHide();
setChatModeId(_chatModeId);
}, [handleModeSelectorHide]);
// Actiles
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
@@ -380,12 +367,12 @@ export function Composer(props: {
touchShiftEnter();
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (!assistantAbortible)
await handleSendAction(chatModeId, composeText); // enter -> send
await handleSendAction(chatExecuteMode, composeText); // enter -> send
return e.preventDefault();
}
}
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
// Focus mode
@@ -416,7 +403,7 @@ export function Composer(props: {
if (autoSend) {
if (notUserStop)
playSoundUrl('/sounds/mic-off-mid.mp3');
void handleSendAction(chatModeId, nextText); // fire/forget
void handleSendAction(chatExecuteMode, nextText); // fire/forget
} else {
if (!micContinuation && notUserStop)
playSoundUrl('/sounds/mic-off-mid.mp3');
@@ -425,7 +412,7 @@ export function Composer(props: {
setComposeText(nextText);
}
}
}, [chatModeId, composeText, composerTextAreaRef, handleSendAction, micContinuation, noConversation, setComposeText]);
}, [chatExecuteMode, composeText, composerTextAreaRef, handleSendAction, micContinuation, noConversation, setComposeText]);
const { isSpeechEnabled, isSpeechError, isRecordingAudio, isRecordingSpeech, toggleRecording } =
useSpeechRecognition(onSpeechResultCallback, chatMicTimeoutMs || 2000);
@@ -547,11 +534,11 @@ export function Composer(props: {
}, [attachAppendDataTransfer, eatDragEvent, setComposeText]);
const isText = chatModeId === 'generate-content' || chatModeId === 'generate-text-v1';
const isTextBeam = chatModeId === 'beam-content';
const isAppend = chatModeId === 'append-user';
const isReAct = chatModeId === 'react-content';
const isDraw = chatModeId === 'generate-image';
const isText = chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1';
const isTextBeam = chatExecuteMode === 'beam-content';
const isAppend = chatExecuteMode === 'append-user';
const isReAct = chatExecuteMode === 'react-content';
const isDraw = chatExecuteMode === 'generate-image';
const showChatReplyTo = !!replyToGenerateText;
const showChatExtras = isText && !showChatReplyTo;
@@ -873,8 +860,8 @@ export function Composer(props: {
{/* Mode expander */}
<IconButton
variant={assistantAbortible ? 'soft' : isDraw ? undefined : undefined}
disabled={noConversation || noLLM || !!chatModeMenuAnchor}
onClick={handleModeSelectorShow}
disabled={noConversation || noLLM || chatExecuteMenuShown}
onClick={showChatExecuteMenu}
>
<ExpandLessIcon />
</IconButton>
@@ -911,14 +898,7 @@ export function Composer(props: {
</Grid>
{/* Mode Menu */}
{!!chatModeMenuAnchor && (
<ChatModeMenu
isMobile={isMobile}
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
capabilityHasTTI={props.capabilityHasT2I}
/>
)}
{chatExecuteMenuComponent}
{/* Camera (when open) */}
{cameraCaptureComponent}
+5 -5
View File
@@ -8,7 +8,7 @@ import { createTextContentFragment, isContentFragment, isTextPart } from '~/comm
import { getConversationSystemPurposeId } from '~/common/stores/chat/store-chats';
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
import type { ChatModeId } from '../AppChat';
import type { ChatExecuteMode } from '../execute-mode/execute-mode.types';
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
import { textToDrawCommand } from '../commands/CommandsDraw';
@@ -19,7 +19,7 @@ import { runPersonaUpdatingState } from './chat-persona';
import { runReActUpdatingState } from './react-tangent';
export async function _handleExecute(chatModeId: ChatModeId, conversationId: DConversationId, executeCallerNameDebug: string) {
export async function _handleExecute(chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, executeCallerNameDebug: string) {
// Handle missing conversation
if (!conversationId)
@@ -46,7 +46,7 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo
// Handle unconfigured
if (!chatLLMId || !chatModeId)
if (!chatLLMId || !chatExecuteMode)
return !chatLLMId ? 'err-no-chatllm' : 'err-no-chatmode';
// handle missing last user message (or fragment)
@@ -71,7 +71,7 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo
}
// synchronous long-duration tasks, which update the state as they go
switch (chatModeId) {
switch (chatExecuteMode) {
case 'generate-content':
return await runPersonaUpdatingState(conversationId, chatLLMId);
@@ -102,7 +102,7 @@ export async function _handleExecute(chatModeId: ChatModeId, conversationId: DCo
return await runReActUpdatingState(cHandler, reactPrompt, chatLLMId);
default:
console.log('Chat execute: issue running', chatModeId, conversationId, lastMessage);
console.log('Chat execute: issue running', chatExecuteMode, conversationId, lastMessage);
return false;
}
}
@@ -0,0 +1,71 @@
import * as React from 'react';
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import type { ChatExecuteMode } from './execute-mode.types';
import { ExecuteModeItems } from './execute-mode.items';
export function ExecuteModeMenu(props: {
isMobile: boolean,
hasCapabilityT2I: boolean,
anchorEl: HTMLAnchorElement | null,
onClose: () => void,
chatExecuteMode: ChatExecuteMode,
onSetChatExecuteMode: (chatExecuteMode: ChatExecuteMode) => void,
}) {
// external state
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
return (
<CloseableMenu
placement='top-end'
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/* Items */}
{Object.entries(ExecuteModeItems)
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatExecuteMode(key as ChatExecuteMode)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio color={data.highlight ? 'success' : undefined} checked={key === props.chatExecuteMode} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.hasCapabilityT2I) ? 'Unconfigured' : ''}</Typography>
</Box>
{(key === props.chatExecuteMode || !!data.shortcut) && (
<KeyStroke combo={platformAwareKeystrokes(
newLineShortcut(
(key === props.chatExecuteMode) ? 'ENTER'
: data.shortcut ? data.shortcut
: 'ENTER',
enterIsNewline,
),
)} />
)}
</Box>
</MenuItem>,
)}
</CloseableMenu>
);
}
function newLineShortcut(shortcut: string, enterIsNewLine: boolean) {
if (shortcut === 'ENTER')
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
return shortcut;
}
@@ -0,0 +1,50 @@
import * as React from 'react';
import type { ChatExecuteMode } from './execute-mode.types';
interface ModeDescription {
label: string;
description: string | React.JSX.Element;
canAttach?: boolean;
highlight?: boolean;
shortcut?: string;
hideOnDesktop?: boolean;
requiresTTI?: boolean;
}
export const ExecuteModeItems: { [key in ChatExecuteMode]: ModeDescription } = {
'generate-content': {
label: 'Chat',
description: 'Persona replies',
canAttach: true,
},
'generate-text-v1': {
label: 'Chat (Stable)',
description: 'Model replies (stable)',
canAttach: true,
},
'beam-content': {
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
description: 'Combine multiple models', // Smarter: combine...
shortcut: 'Ctrl + Enter',
canAttach: true,
hideOnDesktop: true,
},
'append-user': {
label: 'Write',
description: 'Append a message',
shortcut: 'Alt + Enter',
canAttach: true,
},
'generate-image': {
label: 'Draw',
description: 'AI Image Generation',
requiresTTI: true,
},
'react-content': {
label: 'Reason + Act', // · α
description: 'Answer questions in multiple steps',
},
};
@@ -0,0 +1,12 @@
/**
* Mode: how to treat the input from the Composer
* Was: ChatModeId
*/
export type ChatExecuteMode =
| 'append-user'
| 'beam-content'
| 'generate-content'
| 'generate-image'
| 'generate-text-v1'
| 'react-content'
;
@@ -0,0 +1,50 @@
import * as React from 'react';
import type { ChatExecuteMode } from './execute-mode.types';
import { ExecuteModeMenu } from './ExecuteModeMenu';
import { ExecuteModeItems } from './execute-mode.items';
export function chatExecuteModeCanAttach(chatExecuteMode: ChatExecuteMode) {
return !!ExecuteModeItems[chatExecuteMode]?.canAttach;
}
export function useChatExecuteMode(capabilityHasT2I: boolean, isMobile: boolean) {
// state
const [chatExecuteMode, setChatExecuteMode] = React.useState<ChatExecuteMode>('generate-content');
const [chatExecuteModeMenuAnchor, setChatExecuteModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
const handleMenuHide = React.useCallback(() => setChatExecuteModeMenuAnchor(null), []);
const handleMenuShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
setChatExecuteModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
}, []);
const handleChangeMode = React.useCallback((mode: ChatExecuteMode) => {
handleMenuHide();
setChatExecuteMode(mode);
}, [handleMenuHide]);
const chatExecuteMenuComponent = React.useMemo(() => !!chatExecuteModeMenuAnchor && (
<ExecuteModeMenu
isMobile={isMobile}
hasCapabilityT2I={capabilityHasT2I}
anchorEl={chatExecuteModeMenuAnchor}
onClose={handleMenuHide}
chatExecuteMode={chatExecuteMode}
onSetChatExecuteMode={handleChangeMode}
/>
), [capabilityHasT2I, chatExecuteMode, chatExecuteModeMenuAnchor, handleMenuHide, handleChangeMode, isMobile]);
return {
chatExecuteMode,
chatExecuteMenuComponent,
chatExecuteMenuShown: !!chatExecuteModeMenuAnchor,
showChatExecuteMenu: handleMenuShow,
};
}