Files
claudecodeui/src/components/chat/view/ChatInterface.tsx
Haileyesus 63a4869325 feat: play sound for pending tool requests
Reuse the existing notification tone when a chat run pauses for actionable
tool approval.

Track pending permission state inside the realtime handler so the sound plays
when approval first becomes pending, including subscribe recovery, without
replaying for inline plan prompts or duplicate websocket events.
2026-06-23 14:06:55 +03:00

450 lines
14 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useWebSocket } from '../../../contexts/WebSocketContext';
import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types';
import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import { useSessionStore } from '../../../stores/useSessionStore';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
function ChatInterface({
selectedProject,
selectedSession,
ws,
sendMessage,
onFileOpen,
onInputFocusChange,
onSessionProcessing,
onSessionIdle,
processingSessions,
onNavigateToSession,
onSessionEstablished,
onShowSettings,
autoExpandTools,
showRawParameters,
showThinking,
autoScrollToBottom,
sendByCtrlEnter,
externalMessageUpdate,
newSessionTrigger,
onShowAllTasks,
}: ChatInterfaceProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { subscribe } = useWebSocket();
const { t } = useTranslation('chat');
const sessionStore = useSessionStore();
const streamTimerRef = useRef<number | null>(null);
const accumulatedStreamRef = useRef('');
// When each session's `chat.subscribe` was last sent; idle acks older than
// a later local request are discarded as stale.
const statusCheckSentAtRef = useRef(new Map<string, number>());
// Highest live `seq` observed per session. Written by the realtime handler
// on every sequenced frame, read whenever a `chat.subscribe` is sent so the
// server replays only the events this client actually missed.
const lastSeqRef = useRef(new Map<string, number>());
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
accumulatedStreamRef.current = '';
}, []);
const {
provider,
setProvider,
cursorModel,
setCursorModel,
claudeModel,
setClaudeModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
opencodeModel,
setOpenCodeModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
cyclePermissionMode,
providerModelCatalog,
providerModelCacheCatalog,
providerModelsLoading,
providerModelsRefreshing,
hardRefreshProviderModels,
selectProviderModel,
} = useChatProviderState({
selectedSession,
selectedProject,
});
const {
chatMessages,
addMessage,
sessionActivity,
isProcessing,
canAbortSession,
currentSessionId,
setCurrentSessionId,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount,
visibleMessages,
loadEarlierMessages,
loadAllMessages,
allMessagesLoaded,
isLoadingAllMessages,
loadAllJustFinished,
showLoadAllOverlay,
createDiff,
scrollContainerRef,
scrollToBottom,
scrollToBottomAndReset,
handleScroll,
} = useChatSessionState({
selectedProject,
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
onSessionIdle,
resetStreamingState,
statusCheckSentAtRef,
lastSeqRef,
sessionStore,
});
// Brand-new conversation: the composer allocated a stable session id via
// the session gateway before the first send. Record it locally and put it
// in the URL — this id never changes again, so there is no later handoff.
const handleSessionEstablished = useCallback<NonNullable<ChatInterfaceProps['onSessionEstablished']>>((sessionId, context) => {
setCurrentSessionId(sessionId);
onSessionEstablished?.(sessionId, context);
onNavigateToSession?.(sessionId);
}, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]);
const {
input,
setInput,
textareaRef,
inputHighlightRef,
isTextareaExpanded,
slashCommandsCount,
filteredCommands,
frequentCommands,
commandQuery,
showCommandMenu,
selectedCommandIndex,
resetCommandMenuState,
handleCommandSelect,
handleToggleCommandMenu,
showFileDropdown,
filteredFiles,
selectedFileIndex,
renderInputWithMentions,
selectFile,
attachedImages,
setAttachedImages,
uploadingImages,
imageErrors,
getRootProps,
getInputProps,
isDragActive,
openImagePicker,
handleSubmit,
handleInputChange,
handleKeyDown,
handlePaste,
handleTextareaClick,
handleTextareaInput,
syncInputOverlayScroll,
handleClearInput,
handleAbortSession,
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused: _isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
} = useChatComposerState({
selectedProject,
selectedSession,
currentSessionId,
provider,
permissionMode,
cyclePermissionMode,
cursorModel,
claudeModel,
codexModel,
geminiModel,
opencodeModel,
isLoading: isProcessing,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionProcessing,
onSessionEstablished: handleSessionEstablished,
onInputFocusChange,
onFileOpen,
onShowSettings,
scrollToBottom,
addMessage,
setIsUserScrolledUp,
setPendingPermissionRequests,
});
// On WebSocket reconnect, re-fetch the current session's messages from the
// server so missed streaming events are shown, then re-subscribe — the
// `chat_subscribed` ack restores or clears the activity indicator, replays
// missed live events, and re-attaches a still-running stream to this socket.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
await sessionStore.refreshFromServer(selectedSession.id);
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSession.id,
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
}],
});
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
useChatRealtimeHandlers({
subscribe,
provider,
selectedSession,
currentSessionId,
setTokenBudget,
pendingPermissionRequests,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionProcessing,
onSessionIdle,
onWebSocketReconnect: handleWebSocketReconnect,
sessionStore,
});
useEffect(() => {
if (!canAbortSession) {
return;
}
const handleGlobalEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) {
return;
}
event.preventDefault();
handleAbortSession();
};
document.addEventListener('keydown', handleGlobalEscape, { capture: true });
return () => {
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
};
}, [canAbortSession, handleAbortSession]);
useEffect(() => {
return () => {
resetStreamingState();
};
}, [resetStreamingState]);
const permissionContextValue = useMemo(() => ({
pendingPermissionRequests,
handlePermissionDecision,
}), [pendingPermissionRequests, handlePermissionDecision]);
if (!selectedProject) {
const selectedProviderLabel =
provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude');
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="text-sm">
{t('projectSelection.startChatWithProvider', {
provider: selectedProviderLabel,
defaultValue: 'Select a project to start chatting with {{provider}}',
})}
</p>
</div>
</div>
);
}
return (
<PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
isLoadingSessionMessages={isLoadingSessionMessages}
isProcessing={isProcessing}
chatMessages={chatMessages}
selectedSession={selectedSession}
currentSessionId={currentSessionId}
provider={provider}
setProvider={(nextProvider) => setProvider(nextProvider as Provider)}
textareaRef={textareaRef}
claudeModel={claudeModel}
setClaudeModel={setClaudeModel}
cursorModel={cursorModel}
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
setInput={setInput}
isLoadingMoreMessages={isLoadingMoreMessages}
hasMoreMessages={hasMoreMessages}
totalMessages={totalMessages}
sessionMessagesCount={chatMessages.length}
visibleMessageCount={visibleMessageCount}
visibleMessages={visibleMessages}
loadEarlierMessages={loadEarlierMessages}
loadAllMessages={loadAllMessages}
allMessagesLoaded={allMessagesLoaded}
isLoadingAllMessages={isLoadingAllMessages}
loadAllJustFinished={loadAllJustFinished}
showLoadAllOverlay={showLoadAllOverlay}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
/>
<ChatComposer
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
activity={sessionActivity}
isLoading={isProcessing}
onAbortSession={handleAbortSession}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}
onClearInput={handleClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={chatMessages.length > 0}
onScrollToBottom={scrollToBottomAndReset}
onSubmit={handleSubmit}
isDragActive={isDragActive}
attachedImages={attachedImages}
onRemoveImage={(index) =>
setAttachedImages((previous) =>
previous.filter((_, currentIndex) => currentIndex !== index),
)
}
uploadingImages={uploadingImages}
imageErrors={imageErrors}
showFileDropdown={showFileDropdown}
filteredFiles={filteredFiles}
selectedFileIndex={selectedFileIndex}
onSelectFile={selectFile}
filteredCommands={filteredCommands}
selectedCommandIndex={selectedCommandIndex}
onCommandSelect={handleCommandSelect}
onCloseCommandMenu={resetCommandMenuState}
isCommandMenuOpen={showCommandMenu}
frequentCommands={commandQuery ? [] : frequentCommands}
getRootProps={getRootProps as (...args: unknown[]) => Record<string, unknown>}
getInputProps={getInputProps as (...args: unknown[]) => Record<string, unknown>}
openImagePicker={openImagePicker}
inputHighlightRef={inputHighlightRef}
renderInputWithMentions={renderInputWithMentions}
textareaRef={textareaRef}
input={input}
onInputChange={handleInputChange}
onTextareaClick={handleTextareaClick}
onTextareaKeyDown={handleKeyDown}
onTextareaPaste={handlePaste}
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange}
placeholder={t('input.placeholder', {
provider:
provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}
/>
</div>
<QuickSettingsPanel />
<CommandResultModal
payload={commandModalPayload}
onClose={closeCommandModal}
providerModelCatalog={providerModelCatalog}
providerModelCacheCatalog={providerModelCacheCatalog}
providerModelsRefreshing={providerModelsRefreshing}
onHardRefreshProviderModels={hardRefreshProviderModels}
currentSessionId={currentSessionId || selectedSession?.id || null}
onSelectProviderModel={selectProviderModel}
/>
</PermissionContext.Provider>
);
}
export default React.memo(ChatInterface);