mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-15 13:17:32 +00:00
Problem
Stop requests were unreliable because aborting depended on currentSessionId being set, Esc had no actual global abort binding, stale pending session ids could be reused, and abort failures were surfaced as successful interruptions. Codex sessions also used a soft abort flag without wiring SDK cancellation.
Changes
- Add global Escape key handler in chat while a run is loading/cancellable to trigger the same abort path as the Stop button.
- Harden abort session target selection in composer by resolving from multiple active session id sources (current, pending view, pending storage, cursor storage, selected session) and ignoring temporary new-session-* ids.
- Clear stale pendingSessionId when launching a brand-new session to prevent aborting an old run.
- Update realtime abort handling to respect backend success=false responses: keep loading state when abort fails and emit an explicit failure message instead of pretending interruption succeeded.
- Improve websocket send reliability by checking socket.readyState === WebSocket.OPEN directly before send.
- Implement real Codex cancellation via AbortController + runStreamed(..., { signal }), propagate aborted status, and suppress expected abort-error noise.
Impact
This makes both UI Stop and Esc-to-stop materially more reliable across Claude/Cursor/Codex flows, especially during early-session windows before currentSessionId is finalized, and prevents false-positive interrupted states when backend cancellation fails.
Validation
- npm run -s typecheck
- npm run -s build
- node --check server/openai-codex.js
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import React, { useCallback, useEffect, useRef } from 'react';
|
|
import QuickSettingsPanel from './QuickSettingsPanel';
|
|
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
|
import { useTranslation } from 'react-i18next';
|
|
import ChatMessagesPane from './chat/view/ChatMessagesPane';
|
|
import ChatComposer from './chat/view/ChatComposer';
|
|
import type { ChatInterfaceProps } from './chat/types';
|
|
import { useChatProviderState } from '../hooks/chat/useChatProviderState';
|
|
import { useChatSessionState } from '../hooks/chat/useChatSessionState';
|
|
import { useChatRealtimeHandlers } from '../hooks/chat/useChatRealtimeHandlers';
|
|
import { useChatComposerState } from '../hooks/chat/useChatComposerState';
|
|
import type { Provider } from './chat/types';
|
|
|
|
type PendingViewSession = {
|
|
sessionId: string | null;
|
|
startedAt: number;
|
|
};
|
|
|
|
function ChatInterface({
|
|
selectedProject,
|
|
selectedSession,
|
|
ws,
|
|
sendMessage,
|
|
latestMessage,
|
|
onFileOpen,
|
|
onInputFocusChange,
|
|
onSessionActive,
|
|
onSessionInactive,
|
|
onSessionProcessing,
|
|
onSessionNotProcessing,
|
|
processingSessions,
|
|
onReplaceTemporarySession,
|
|
onNavigateToSession,
|
|
onShowSettings,
|
|
autoExpandTools,
|
|
showRawParameters,
|
|
showThinking,
|
|
autoScrollToBottom,
|
|
sendByCtrlEnter,
|
|
externalMessageUpdate,
|
|
onShowAllTasks,
|
|
}: ChatInterfaceProps) {
|
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
|
const { t } = useTranslation('chat');
|
|
|
|
const streamBufferRef = useRef('');
|
|
const streamTimerRef = useRef<number | null>(null);
|
|
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
|
|
|
const resetStreamingState = useCallback(() => {
|
|
if (streamTimerRef.current) {
|
|
clearTimeout(streamTimerRef.current);
|
|
streamTimerRef.current = null;
|
|
}
|
|
streamBufferRef.current = '';
|
|
}, []);
|
|
|
|
const {
|
|
provider,
|
|
setProvider,
|
|
cursorModel,
|
|
setCursorModel,
|
|
claudeModel,
|
|
setClaudeModel,
|
|
codexModel,
|
|
setCodexModel,
|
|
permissionMode,
|
|
pendingPermissionRequests,
|
|
setPendingPermissionRequests,
|
|
cyclePermissionMode,
|
|
} = useChatProviderState({
|
|
selectedSession,
|
|
});
|
|
|
|
const {
|
|
chatMessages,
|
|
setChatMessages,
|
|
isLoading,
|
|
setIsLoading,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
sessionMessages,
|
|
setSessionMessages,
|
|
isLoadingSessionMessages,
|
|
isLoadingMoreMessages,
|
|
hasMoreMessages,
|
|
totalMessages,
|
|
isSystemSessionChange,
|
|
setIsSystemSessionChange,
|
|
canAbortSession,
|
|
setCanAbortSession,
|
|
isUserScrolledUp,
|
|
setIsUserScrolledUp,
|
|
tokenBudget,
|
|
setTokenBudget,
|
|
visibleMessageCount,
|
|
visibleMessages,
|
|
loadEarlierMessages,
|
|
claudeStatus,
|
|
setClaudeStatus,
|
|
createDiff,
|
|
scrollContainerRef,
|
|
scrollToBottom,
|
|
handleScroll,
|
|
} = useChatSessionState({
|
|
selectedProject,
|
|
selectedSession,
|
|
ws,
|
|
sendMessage,
|
|
autoScrollToBottom,
|
|
externalMessageUpdate,
|
|
processingSessions,
|
|
resetStreamingState,
|
|
pendingViewSessionRef,
|
|
});
|
|
|
|
const {
|
|
input,
|
|
setInput,
|
|
textareaRef,
|
|
inputHighlightRef,
|
|
isTextareaExpanded,
|
|
thinkingMode,
|
|
setThinkingMode,
|
|
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,
|
|
handleTranscript,
|
|
handlePermissionDecision,
|
|
handleGrantToolPermission,
|
|
handleInputFocusChange,
|
|
} = useChatComposerState({
|
|
selectedProject,
|
|
selectedSession,
|
|
currentSessionId,
|
|
provider,
|
|
permissionMode,
|
|
cyclePermissionMode,
|
|
cursorModel,
|
|
claudeModel,
|
|
codexModel,
|
|
isLoading,
|
|
canAbortSession,
|
|
tokenBudget,
|
|
sendMessage,
|
|
sendByCtrlEnter,
|
|
onSessionActive,
|
|
onInputFocusChange,
|
|
onFileOpen,
|
|
onShowSettings,
|
|
pendingViewSessionRef,
|
|
scrollToBottom,
|
|
setChatMessages,
|
|
setSessionMessages,
|
|
setIsLoading,
|
|
setCanAbortSession,
|
|
setClaudeStatus,
|
|
setIsUserScrolledUp,
|
|
setPendingPermissionRequests,
|
|
});
|
|
|
|
useChatRealtimeHandlers({
|
|
latestMessage,
|
|
provider,
|
|
selectedProject,
|
|
selectedSession,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
setChatMessages,
|
|
setIsLoading,
|
|
setCanAbortSession,
|
|
setClaudeStatus,
|
|
setTokenBudget,
|
|
setIsSystemSessionChange,
|
|
setPendingPermissionRequests,
|
|
pendingViewSessionRef,
|
|
streamBufferRef,
|
|
streamTimerRef,
|
|
onSessionInactive,
|
|
onSessionProcessing,
|
|
onSessionNotProcessing,
|
|
onReplaceTemporarySession,
|
|
onNavigateToSession,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isLoading || !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, isLoading]);
|
|
|
|
useEffect(() => {
|
|
if (currentSessionId && isLoading && onSessionProcessing) {
|
|
onSessionProcessing(currentSessionId);
|
|
}
|
|
}, [currentSessionId, isLoading, onSessionProcessing]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
resetStreamingState();
|
|
};
|
|
}, [resetStreamingState]);
|
|
|
|
if (!selectedProject) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
|
<p>Select a project to start chatting with Claude</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="h-full flex flex-col">
|
|
<ChatMessagesPane
|
|
scrollContainerRef={scrollContainerRef}
|
|
onWheel={handleScroll}
|
|
onTouchMove={handleScroll}
|
|
isLoadingSessionMessages={isLoadingSessionMessages}
|
|
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}
|
|
tasksEnabled={tasksEnabled}
|
|
isTaskMasterInstalled={isTaskMasterInstalled}
|
|
onShowAllTasks={onShowAllTasks}
|
|
setInput={setInput}
|
|
isLoadingMoreMessages={isLoadingMoreMessages}
|
|
hasMoreMessages={hasMoreMessages}
|
|
totalMessages={totalMessages}
|
|
sessionMessagesCount={sessionMessages.length}
|
|
visibleMessageCount={visibleMessageCount}
|
|
visibleMessages={visibleMessages}
|
|
loadEarlierMessages={loadEarlierMessages}
|
|
createDiff={createDiff}
|
|
onFileOpen={onFileOpen}
|
|
onShowSettings={onShowSettings}
|
|
onGrantToolPermission={handleGrantToolPermission}
|
|
autoExpandTools={autoExpandTools}
|
|
showRawParameters={showRawParameters}
|
|
showThinking={showThinking}
|
|
selectedProject={selectedProject}
|
|
isLoading={isLoading}
|
|
/>
|
|
|
|
<ChatComposer
|
|
pendingPermissionRequests={pendingPermissionRequests}
|
|
handlePermissionDecision={handlePermissionDecision}
|
|
handleGrantToolPermission={handleGrantToolPermission}
|
|
claudeStatus={claudeStatus}
|
|
isLoading={isLoading}
|
|
onAbortSession={handleAbortSession}
|
|
provider={provider}
|
|
permissionMode={permissionMode}
|
|
onModeSwitch={cyclePermissionMode}
|
|
thinkingMode={thinkingMode}
|
|
setThinkingMode={setThinkingMode}
|
|
tokenBudget={tokenBudget}
|
|
slashCommandsCount={slashCommandsCount}
|
|
onToggleCommandMenu={handleToggleCommandMenu}
|
|
hasInput={Boolean(input.trim())}
|
|
onClearInput={handleClearInput}
|
|
isUserScrolledUp={isUserScrolledUp}
|
|
hasMessages={chatMessages.length > 0}
|
|
onScrollToBottom={scrollToBottom}
|
|
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')
|
|
: t('messageTypes.claude'),
|
|
})}
|
|
isTextareaExpanded={isTextareaExpanded}
|
|
sendByCtrlEnter={sendByCtrlEnter}
|
|
onTranscript={handleTranscript}
|
|
/>
|
|
</div>
|
|
|
|
<QuickSettingsPanel />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default React.memo(ChatInterface);
|