mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-24 11:15:48 +08:00
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.
450 lines
14 KiB
TypeScript
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);
|