dashboard-nanobot/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx

693 lines
29 KiB
TypeScript

import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Pencil, Plus, RefreshCw, RotateCcw, Square, Trash2, X } from 'lucide-react';
import type { Components } from 'react-markdown';
import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png';
import type { ChatMessage } from '../../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
import { normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
import type { StagedSubmissionDraft } from '../types';
import { formatDateInputValue, workspaceFileAction } from '../utils';
import { DashboardConversationMessages } from './DashboardConversationMessages';
import './DashboardChatPanel.css';
interface DashboardChatPanelLabels {
badReply: string;
botDisabledHint: string;
botStarting: string;
botStopping: string;
chatDisabled: string;
close: string;
controlCommandsHide: string;
controlCommandsShow: string;
copyPrompt: string;
copyReply: string;
deleteMessage: string;
disabledPlaceholder: string;
download: string;
editPrompt: string;
fileNotPreviewable: string;
goodReply: string;
inputPlaceholder: string;
interrupt: string;
noConversation: string;
previewTitle: string;
stagedSubmissionAttachmentCount: (count: number) => string;
stagedSubmissionEmpty: string;
stagedSubmissionRestore: string;
stagedSubmissionRemove: string;
quoteReply: string;
quotedReplyLabel: string;
send: string;
thinking: string;
uploadFile: string;
uploadingFile: string;
user: string;
voiceStart: string;
voiceStop: string;
voiceTranscribing: string;
you: string;
}
interface DashboardChatPanelProps {
conversation: ChatMessage[];
isZh: boolean;
labels: DashboardChatPanelLabels;
chatScrollRef: RefObject<HTMLDivElement | null>;
onChatScroll: () => void;
expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>;
deletingMessageIdMap: Record<number, boolean>;
feedbackSavingByMessageId: Record<number, boolean>;
markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>;
onToggleProgressExpand: (key: string) => void;
onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void;
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void;
onCopyAssistantReply: (text: string) => Promise<void> | void;
isThinking: boolean;
canChat: boolean;
isChatEnabled: boolean;
speechEnabled: boolean;
selectedBotEnabled: boolean;
selectedBotControlState?: 'starting' | 'stopping' | 'enabling' | 'disabling';
quotedReply: { text: string } | null;
onClearQuotedReply: () => void;
stagedSubmissions: StagedSubmissionDraft[];
onRestoreStagedSubmission: (stagedSubmissionId: string) => void;
onRemoveStagedSubmission: (stagedSubmissionId: string) => void;
pendingAttachments: string[];
onRemovePendingAttachment: (path: string) => void;
attachmentUploadPercent: number | null;
isUploadingAttachments: boolean;
filePickerRef: RefObject<HTMLInputElement | null>;
allowedAttachmentExtensions: string[];
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
controlCommandPanelOpen: boolean;
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
onToggleControlCommandPanel: () => void;
activeControlCommand: string;
canSendControlCommand: boolean;
isInterrupting: boolean;
onSendControlCommand: (command: '/restart' | '/new') => Promise<void> | void;
onInterruptExecution: () => Promise<void> | void;
chatDateTriggerRef: RefObject<HTMLButtonElement | null>;
hasSelectedBot: boolean;
chatDateJumping: boolean;
onToggleChatDatePicker: () => void;
chatDatePickerOpen: boolean;
chatDatePanelPosition: { bottom: number; right: number } | null;
chatDateValue: string;
onChatDateValueChange: (value: string) => void;
onCloseChatDatePicker: () => void;
onJumpConversationToDate: () => Promise<void> | void;
command: string;
onCommandChange: (value: string) => void;
composerTextareaRef: RefObject<HTMLTextAreaElement | null>;
onComposerKeyDown: KeyboardEventHandler<HTMLTextAreaElement>;
isVoiceRecording: boolean;
isVoiceTranscribing: boolean;
isCompactMobile: boolean;
voiceCountdown: number;
onVoiceInput: () => Promise<void> | void;
onTriggerPickAttachments: () => Promise<void> | void;
submitActionMode: 'interrupt' | 'send' | 'stage';
onSubmitAction: () => Promise<void> | void;
}
interface DashboardChatTranscriptProps {
conversation: ChatMessage[];
isZh: boolean;
labels: DashboardChatPanelLabels;
chatScrollRef: RefObject<HTMLDivElement | null>;
onChatScroll: () => void;
expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>;
deletingMessageIdMap: Record<number, boolean>;
feedbackSavingByMessageId: Record<number, boolean>;
markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>;
onToggleProgressExpand: (key: string) => void;
onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void;
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void;
onCopyAssistantReply: (text: string) => Promise<void> | void;
isThinking: boolean;
}
const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
conversation,
isZh,
labels,
chatScrollRef,
onChatScroll,
expandedProgressByKey,
expandedUserByKey,
deletingMessageIdMap,
feedbackSavingByMessageId,
markdownComponents,
workspaceDownloadExtensionSet,
onToggleProgressExpand,
onToggleUserExpand,
onEditUserPrompt,
onCopyUserPrompt,
onDeleteConversationMessage,
onOpenWorkspacePath,
onSubmitAssistantFeedback,
onQuoteAssistantReply,
onCopyAssistantReply,
isThinking,
}: DashboardChatTranscriptProps) {
return (
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
{conversation.length === 0 ? (
<div className="ops-chat-empty">
{labels.noConversation}
</div>
) : (
<DashboardConversationMessages
conversation={conversation}
isZh={isZh}
labels={{
badReply: labels.badReply,
copyPrompt: labels.copyPrompt,
copyReply: labels.copyReply,
deleteMessage: labels.deleteMessage,
download: labels.download,
editPrompt: labels.editPrompt,
fileNotPreviewable: labels.fileNotPreviewable,
goodReply: labels.goodReply,
previewTitle: labels.previewTitle,
quoteReply: labels.quoteReply,
quotedReplyLabel: labels.quotedReplyLabel,
user: labels.user,
you: labels.you,
}}
expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey}
deletingMessageIdMap={deletingMessageIdMap}
feedbackSavingByMessageId={feedbackSavingByMessageId}
markdownComponents={markdownComponents}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
onToggleProgressExpand={onToggleProgressExpand}
onToggleUserExpand={onToggleUserExpand}
onEditUserPrompt={onEditUserPrompt}
onCopyUserPrompt={onCopyUserPrompt}
onDeleteConversationMessage={onDeleteConversationMessage}
onOpenWorkspacePath={onOpenWorkspacePath}
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
onQuoteAssistantReply={onQuoteAssistantReply}
onCopyAssistantReply={onCopyAssistantReply}
/>
)}
{isThinking ? (
<div className="ops-chat-row is-assistant">
<div className="ops-chat-item is-assistant">
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
<div className="ops-thinking-bubble">
<div className="ops-thinking-cloud">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
<div className="ops-thinking-text">{labels.thinking}</div>
</div>
</div>
</div>
) : null}
<div />
</div>
);
}, (prev, next) => (
prev.conversation === next.conversation
&& prev.isZh === next.isZh
&& prev.isThinking === next.isThinking
&& prev.chatScrollRef === next.chatScrollRef
&& prev.expandedProgressByKey === next.expandedProgressByKey
&& prev.expandedUserByKey === next.expandedUserByKey
&& prev.deletingMessageIdMap === next.deletingMessageIdMap
&& prev.feedbackSavingByMessageId === next.feedbackSavingByMessageId
&& prev.markdownComponents === next.markdownComponents
&& prev.workspaceDownloadExtensionSet === next.workspaceDownloadExtensionSet
&& prev.labels.badReply === next.labels.badReply
&& prev.labels.copyPrompt === next.labels.copyPrompt
&& prev.labels.copyReply === next.labels.copyReply
&& prev.labels.deleteMessage === next.labels.deleteMessage
&& prev.labels.download === next.labels.download
&& prev.labels.editPrompt === next.labels.editPrompt
&& prev.labels.fileNotPreviewable === next.labels.fileNotPreviewable
&& prev.labels.goodReply === next.labels.goodReply
&& prev.labels.noConversation === next.labels.noConversation
&& prev.labels.previewTitle === next.labels.previewTitle
&& prev.labels.quoteReply === next.labels.quoteReply
&& prev.labels.quotedReplyLabel === next.labels.quotedReplyLabel
&& prev.labels.thinking === next.labels.thinking
&& prev.labels.user === next.labels.user
&& prev.labels.you === next.labels.you
));
export function DashboardChatPanel({
conversation,
isZh,
labels,
chatScrollRef,
onChatScroll,
expandedProgressByKey,
expandedUserByKey,
deletingMessageIdMap,
feedbackSavingByMessageId,
markdownComponents,
workspaceDownloadExtensionSet,
onToggleProgressExpand,
onToggleUserExpand,
onEditUserPrompt,
onCopyUserPrompt,
onDeleteConversationMessage,
onOpenWorkspacePath,
onSubmitAssistantFeedback,
onQuoteAssistantReply,
onCopyAssistantReply,
isThinking,
canChat,
isChatEnabled,
speechEnabled,
selectedBotEnabled,
selectedBotControlState,
quotedReply,
onClearQuotedReply,
stagedSubmissions,
onRestoreStagedSubmission,
onRemoveStagedSubmission,
pendingAttachments,
onRemovePendingAttachment,
attachmentUploadPercent,
isUploadingAttachments,
filePickerRef,
allowedAttachmentExtensions,
onPickAttachments,
controlCommandPanelOpen,
controlCommandPanelRef,
onToggleControlCommandPanel,
activeControlCommand,
canSendControlCommand,
isInterrupting,
onSendControlCommand,
onInterruptExecution,
chatDateTriggerRef,
hasSelectedBot,
chatDateJumping,
onToggleChatDatePicker,
chatDatePickerOpen,
chatDatePanelPosition,
chatDateValue,
onChatDateValueChange,
onCloseChatDatePicker,
onJumpConversationToDate,
command,
onCommandChange,
composerTextareaRef,
onComposerKeyDown,
isVoiceRecording,
isVoiceTranscribing,
isCompactMobile,
voiceCountdown,
onVoiceInput,
onTriggerPickAttachments,
submitActionMode,
onSubmitAction,
}: DashboardChatPanelProps) {
const showInterruptSubmitAction = submitActionMode === 'interrupt';
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
return (
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<MemoizedChatTranscript
conversation={conversation}
isZh={isZh}
labels={labels}
chatScrollRef={chatScrollRef}
onChatScroll={onChatScroll}
expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey}
deletingMessageIdMap={deletingMessageIdMap}
feedbackSavingByMessageId={feedbackSavingByMessageId}
markdownComponents={markdownComponents}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
onToggleProgressExpand={onToggleProgressExpand}
onToggleUserExpand={onToggleUserExpand}
onEditUserPrompt={onEditUserPrompt}
onCopyUserPrompt={onCopyUserPrompt}
onDeleteConversationMessage={onDeleteConversationMessage}
onOpenWorkspacePath={onOpenWorkspacePath}
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
onQuoteAssistantReply={onQuoteAssistantReply}
onCopyAssistantReply={onCopyAssistantReply}
isThinking={isThinking}
/>
<div className="ops-chat-dock">
{stagedSubmissions.length > 0 ? (
<div className="ops-staged-submission-queue" aria-live="polite">
{stagedSubmissions.map((stagedSubmission, index) => (
<div key={stagedSubmission.id} className="ops-staged-submission-item">
<span className="ops-staged-submission-index mono">{index + 1}</span>
<div className="ops-staged-submission-body">
<div className="ops-staged-submission-text">
{normalizeUserMessageText(stagedSubmission.command) || labels.stagedSubmissionEmpty}
</div>
{(stagedSubmission.quotedReply || stagedSubmission.attachments.length > 0) ? (
<div className="ops-staged-submission-meta">
{stagedSubmission.quotedReply ? (
<span className="ops-staged-submission-pill">{labels.quotedReplyLabel}</span>
) : null}
{stagedSubmission.attachments.length > 0 ? (
<span className="ops-staged-submission-pill">
{labels.stagedSubmissionAttachmentCount(stagedSubmission.attachments.length)}
</span>
) : null}
</div>
) : null}
</div>
<div className="ops-staged-submission-actions">
<button
type="button"
className="ops-staged-submission-icon-btn"
onClick={() => onRestoreStagedSubmission(stagedSubmission.id)}
aria-label={labels.stagedSubmissionRestore}
title={labels.stagedSubmissionRestore}
>
<Pencil size={14} />
</button>
<button
type="button"
className="ops-staged-submission-icon-btn"
onClick={() => onRemoveStagedSubmission(stagedSubmission.id)}
aria-label={labels.stagedSubmissionRemove}
title={labels.stagedSubmissionRemove}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
) : null}
{(quotedReply || pendingAttachments.length > 0) ? (
<div className="ops-chat-top-context">
{quotedReply ? (
<div className="ops-composer-quote" aria-live="polite">
<div className="ops-composer-quote-head">
<span>{labels.quotedReplyLabel}</span>
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={onClearQuotedReply}
>
<X size={12} />
</button>
</div>
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
</div>
) : null}
{pendingAttachments.length > 0 ? (
<div className="ops-pending-files">
{pendingAttachments.map((path) => (
<span key={path} className="ops-pending-chip mono">
{(() => {
const filePath = normalizeDashboardAttachmentPath(path);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
className="ops-attach-link mono ops-pending-open"
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onOpenWorkspacePath(filePath);
}}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})()}
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemovePendingAttachment(path);
}}
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
</div>
) : null}
{isUploadingAttachments ? (
<div className="ops-upload-progress" aria-live="polite">
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
<div
className="ops-upload-progress-fill"
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
/>
</div>
<span className="ops-upload-progress-text mono">
{attachmentUploadPercent === null
? labels.uploadingFile
: `${labels.uploadingFile} ${attachmentUploadPercent}%`}
</span>
</div>
) : null}
<div className="ops-composer">
<input
ref={filePickerRef}
type="file"
multiple
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
onChange={onPickAttachments}
className="ops-hidden-file-input"
/>
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onSendControlCommand('/restart')}
aria-label="/restart"
title="/restart"
>
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
<span className="mono">/restart</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onSendControlCommand('/new')}
aria-label="/new"
title="/new"
>
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
<span className="mono">/new</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!hasSelectedBot || !canChat || Boolean(activeControlCommand) || isInterrupting}
onClick={() => void onInterruptExecution()}
aria-label="/stop"
title="/stop"
>
{isInterrupting ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span>
</button>
<button
type="button"
className="ops-control-command-chip"
ref={chatDateTriggerRef}
disabled={!hasSelectedBot || chatDateJumping}
onClick={onToggleChatDatePicker}
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
title={isZh ? '按日期定位对话' : 'Jump to date'}
>
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
<span className="mono">/time</span>
</button>
</div>
{chatDatePickerOpen ? (
<div
className="ops-control-date-panel"
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
>
<label className="ops-control-date-label">
<span>{isZh ? '选择日期' : 'Select date'}</span>
<input
className="input ops-control-date-input"
type="date"
value={chatDateValue}
max={formatDateInputValue(Date.now())}
onChange={(event) => onChatDateValueChange(event.target.value)}
/>
</label>
<div className="ops-control-date-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onCloseChatDatePicker}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={chatDateJumping || !chatDateValue}
onClick={() => void onJumpConversationToDate()}
>
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
<span className={chatDateJumping ? 'ops-control-date-submit-label' : undefined}>
{isZh ? '跳转' : 'Jump'}
</span>
</button>
</div>
</div>
) : null}
<button
type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
onClick={onToggleControlCommandPanel}
aria-label={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
title={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
>
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
</button>
</div>
<textarea
ref={composerTextareaRef}
className="input ops-composer-input"
value={command}
onChange={(event) => onCommandChange(event.target.value)}
onKeyDown={onComposerKeyDown}
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
placeholder={canChat ? labels.inputPlaceholder : labels.disabledPlaceholder}
/>
<div className="ops-composer-tools-right">
{(isVoiceRecording || isVoiceTranscribing) ? (
<div className="ops-voice-inline" aria-live="polite">
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
const delayIndex = isCompactMobile ? idx : (segmentIdx * 18) + idx;
return (
<i
key={`vw-inline-${segmentIdx}-${idx}`}
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
/>
);
})}
</div>
))}
</div>
<div className="ops-voice-countdown mono">
{isVoiceRecording ? `${voiceCountdown}s` : labels.voiceTranscribing}
</div>
</div>
) : null}
<button
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || !speechEnabled || isVoiceTranscribing}
onClick={() => void onVoiceInput()}
aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart}
title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart}
>
{isVoiceTranscribing ? (
<RefreshCw size={16} className="animate-spin" />
) : isVoiceRecording ? (
<Square size={16} />
) : (
<Mic size={16} />
)}
</button>
<LucentIconButton
className="ops-composer-inline-btn"
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
onClick={() => void onTriggerPickAttachments()}
tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
>
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
</LucentIconButton>
<button
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
disabled={
submitActionMode === 'interrupt'
? isInterrupting
: (
submitActionMode === 'stage'
? (
isVoiceRecording
|| isVoiceTranscribing
|| !hasComposerDraft
)
: (
!isChatEnabled
|| isVoiceRecording
|| isVoiceTranscribing
|| !hasComposerDraft
)
)
}
onClick={() => void onSubmitAction()}
aria-label={showInterruptSubmitAction ? labels.interrupt : labels.send}
title={showInterruptSubmitAction ? labels.interrupt : labels.send}
>
{showInterruptSubmitAction ? <Square size={15} /> : <ArrowUp size={18} />}
</button>
</div>
</div>
</div>
</div>
{!canChat ? (
<div className="ops-chat-disabled-mask">
<div className="ops-chat-disabled-card">
{selectedBotControlState === 'starting'
? labels.botStarting
: selectedBotControlState === 'stopping'
? labels.botStopping
: !selectedBotEnabled
? labels.botDisabledHint
: labels.chatDisabled}
</div>
</div>
) : null}
</div>
);
}