diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index a4b5b98..e9e4c9c 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -20,11 +20,10 @@ import type { ChatMessage, PendingPermissionRequest, PermissionMode, - Provider, } from '../types/types'; import { useFileMentions } from './useFileMentions'; import { type SlashCommand, useSlashCommands } from './useSlashCommands'; -import type { Project, ProjectSession } from '../../../types/app'; +import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; type PendingViewSession = { @@ -36,7 +35,7 @@ interface UseChatComposerStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; - provider: Provider | string; + provider: SessionProvider; permissionMode: PermissionMode | string; cyclePermissionMode: () => void; cursorModel: string; diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 126d1ba..e2d98e5 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants'; import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types'; -import type { ProjectSession } from '../../../types/app'; +import type { ProjectSession, SessionProvider } from '../../../types/app'; interface UseChatProviderStateArgs { selectedSession: ProjectSession | null; @@ -11,8 +11,8 @@ interface UseChatProviderStateArgs { export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) { const [permissionMode, setPermissionMode] = useState('default'); const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); - const [provider, setProvider] = useState(() => { - return (localStorage.getItem('selected-provider') as Provider) || 'claude'; + const [provider, setProvider] = useState(() => { + return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; }); const [cursorModel, setCursorModel] = useState(() => { return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 9182811..9d2071b 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,9 +1,9 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting'; import { safeLocalStorage } from '../utils/chatStorage'; -import type { ChatMessage, PendingPermissionRequest, Provider } from '../types/types'; -import type { Project, ProjectSession } from '../../../types/app'; +import type { ChatMessage, PendingPermissionRequest } from '../types/types'; +import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; type PendingViewSession = { sessionId: string | null; @@ -28,7 +28,7 @@ type LatestChatMessage = { interface UseChatRealtimeHandlersArgs { latestMessage: LatestChatMessage | null; - provider: Provider | string; + provider: SessionProvider; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; @@ -114,11 +114,19 @@ export function useChatRealtimeHandlers({ onReplaceTemporarySession, onNavigateToSession, }: UseChatRealtimeHandlersArgs) { + const lastProcessedMessageRef = useRef(null); + useEffect(() => { if (!latestMessage) { return; } + // Guard against duplicate processing when dependency updates occur without a new message object. + if (lastProcessedMessageRef.current === latestMessage) { + return; + } + lastProcessedMessageRef.current = latestMessage; + const messageData = latestMessage.data?.message || latestMessage.data; const structuredMessageData = messageData && typeof messageData === 'object' ? (messageData as Record) : null; @@ -925,5 +933,24 @@ export function useChatRealtimeHandlers({ default: break; } - }, [latestMessage]); + }, [ + latestMessage, + provider, + selectedProject, + selectedSession, + currentSessionId, + setCurrentSessionId, + setChatMessages, + setIsLoading, + setCanAbortSession, + setClaudeStatus, + setTokenBudget, + setIsSystemSessionChange, + setPendingPermissionRequests, + onSessionInactive, + onSessionProcessing, + onSessionNotProcessing, + onReplaceTemporarySession, + onNavigateToSession, + ]); } diff --git a/src/components/chat/tools/components/CollapsibleDisplay.tsx b/src/components/chat/tools/components/CollapsibleDisplay.tsx index 9d833df..f429cf5 100644 --- a/src/components/chat/tools/components/CollapsibleDisplay.tsx +++ b/src/components/chat/tools/components/CollapsibleDisplay.tsx @@ -38,7 +38,8 @@ export const CollapsibleDisplay: React.FC = ({ className = '', toolCategory }) => { - const borderColor = borderColorMap[toolCategory || 'default']; + // Fall back to default styling for unknown/new categories so className never includes "undefined". + const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default; return (
diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts index c2b6958..cd6cd1f 100644 --- a/src/components/chat/utils/messageTransforms.ts +++ b/src/components/chat/utils/messageTransforms.ts @@ -18,6 +18,22 @@ type CursorBlob = { const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []); +const normalizeToolInput = (value: unknown): string => { + if (value === null || value === undefined || value === '') { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + const toAbsolutePath = (projectPath: string, filePath?: string) => { if (!filePath) { return filePath; @@ -149,7 +165,7 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s isToolUse: true, toolName, toolId: toolCallId, - toolInput: null, + toolInput: normalizeToolInput(null), toolResult: { content: result, isError: false, @@ -253,7 +269,7 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s isToolUse: true, toolName, toolId, - toolInput: toolInput ? JSON.stringify(toolInput) : null, + toolInput: normalizeToolInput(toolInput), toolResult: null, }; converted.push(toolMessage); @@ -412,7 +428,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { timestamp: message.timestamp || new Date().toISOString(), isToolUse: true, toolName: message.toolName, - toolInput: message.toolInput || '', + toolInput: normalizeToolInput(message.toolInput), toolCallId: message.toolCallId, }); return; @@ -459,7 +475,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { timestamp: message.timestamp || new Date().toISOString(), isToolUse: true, toolName: part.name, - toolInput: JSON.stringify(part.input), + toolInput: normalizeToolInput(part.input), toolResult: toolResult ? { content: diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index f9910cd..58bed6d 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -245,10 +245,22 @@ function ChatInterface({ }, [resetStreamingState]); if (!selectedProject) { + const selectedProviderLabel = + provider === 'cursor' + ? t('messageTypes.cursor') + : provider === 'codex' + ? t('messageTypes.codex') + : t('messageTypes.claude'); + return (
-

Select a project to start chatting with Claude

+

+ {t('projectSelection.startChatWithProvider', { + provider: selectedProviderLabel, + defaultValue: 'Select a project to start chatting with {{provider}}', + })} +

); diff --git a/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx index d466e66..872dd0c 100644 --- a/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx +++ b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx @@ -1,33 +1,37 @@ -import SessionProviderLogo from "../../../SessionProviderLogo"; -import { Provider } from "../../types/types"; +import { SessionProvider } from '../../../../types/app'; +import SessionProviderLogo from '../../../SessionProviderLogo'; +import type { Provider } from '../../types/types'; -export default function AssistantThinkingIndicator() { - const selectedProvider = (localStorage.getItem('selected-provider') || 'claude') as Provider; +type AssistantThinkingIndicatorProps = { + selectedProvider: SessionProvider; +} - return ( -
-
-
-
- -
-
- {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'} -
-
-
-
-
.
-
- . -
-
- . -
- Thinking... -
-
-
+ +export default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) { + return ( +
+
+
+
+ +
+
+ {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'} +
- ); -} \ No newline at end of file +
+
+
.
+
+ . +
+
+ . +
+ Thinking... +
+
+
+
+ ); +} diff --git a/src/components/chat/view/subcomponents/ChatInputControls.tsx b/src/components/chat/view/subcomponents/ChatInputControls.tsx index ded09be..d4b9a70 100644 --- a/src/components/chat/view/subcomponents/ChatInputControls.tsx +++ b/src/components/chat/view/subcomponents/ChatInputControls.tsx @@ -109,7 +109,7 @@ export default function ChatInputControls({ type="button" onClick={onClearInput} className="w-8 h-8 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center transition-all duration-200 group shadow-sm" - title="Clear input" + title={t('input.clearInput', { defaultValue: 'Clear input' })} > diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index f63ea33..21ad51f 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -4,8 +4,8 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'; import MessageComponent from './MessageComponent'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; -import type { ChatMessage, Provider } from '../../types/types'; -import type { Project, ProjectSession } from '../../../../types/app'; +import type { ChatMessage } from '../../types/types'; +import type { Project, ProjectSession, SessionProvider } from '../../../../types/app'; import AssistantThinkingIndicator from './AssistantThinkingIndicator'; import { getIntrinsicMessageKey } from '../../utils/messageKeys'; @@ -17,8 +17,8 @@ interface ChatMessagesPaneProps { chatMessages: ChatMessage[]; selectedSession: ProjectSession | null; currentSessionId: string | null; - provider: Provider | string; - setProvider: (provider: Provider | string) => void; + provider: SessionProvider; + setProvider: (provider: SessionProvider) => void; textareaRef: RefObject; claudeModel: string; setClaudeModel: (model: string) => void; @@ -201,7 +201,7 @@ export default function ChatMessagesPane({ )} - {isLoading && } + {isLoading && }
); } diff --git a/src/components/chat/view/subcomponents/ImageAttachment.tsx b/src/components/chat/view/subcomponents/ImageAttachment.tsx index 871ddd2..eda2d31 100644 --- a/src/components/chat/view/subcomponents/ImageAttachment.tsx +++ b/src/components/chat/view/subcomponents/ImageAttachment.tsx @@ -32,8 +32,10 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
)} {onShowSettings && ( )}
- Adds {permissionSuggestion.entry} to Allowed Tools. + {t('permissions.addTo', { entry: permissionSuggestion.entry })}
{permissionGrantState === 'error' && (
- Unable to update permissions. Please try again. + {t('permissions.error')}
)} {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
- Permission saved. Retry the request to use the tool. + {t('permissions.retry')}
)} @@ -273,7 +273,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile

- Interactive Prompt + {t('interactive.title')}

{(() => { const lines = (message.content || '').split('\n').filter((line) => line.trim()); @@ -333,10 +333,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile

- ⏳ Waiting for your response in the CLI + {t('interactive.waiting')}

- Please select an option in your terminal where Claude is running. + {t('interactive.instruction')}

@@ -353,7 +353,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile - 💭 Thinking... + {t('thinking.emoji')}
@@ -368,7 +368,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile {showThinking && message.reasoning && (
- 💭 Thinking... + {t('thinking.emoji')}
@@ -395,7 +395,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile - JSON Response + {t('json.response')}
@@ -437,4 +437,5 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
   );
 });
 
-export default MessageComponent;
\ No newline at end of file
+export default MessageComponent;
+
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
index 7c8edd8..35d0572 100644
--- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -3,14 +3,13 @@ import { useTranslation } from 'react-i18next';
 import SessionProviderLogo from '../../../SessionProviderLogo';
 import NextTaskBanner from '../../../NextTaskBanner.jsx';
 import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
-import type { Provider } from '../../types/types';
-import type { ProjectSession } from '../../../../types/app';
+import type { ProjectSession, SessionProvider } from '../../../../types/app';
 
 interface ProviderSelectionEmptyStateProps {
   selectedSession: ProjectSession | null;
   currentSessionId: string | null;
-  provider: Provider | string;
-  setProvider: (next: Provider | string) => void;
+  provider: SessionProvider;
+  setProvider: (next: SessionProvider) => void;
   textareaRef: React.RefObject;
   claudeModel: string;
   setClaudeModel: (model: string) => void;
@@ -42,8 +41,10 @@ export default function ProviderSelectionEmptyState({
   setInput,
 }: ProviderSelectionEmptyStateProps) {
   const { t } = useTranslation('chat');
+  // Reuse one translated prompt so task-start behavior stays consistent across empty and session states.
+  const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
 
-  const selectProvider = (nextProvider: Provider) => {
+  const selectProvider = (nextProvider: SessionProvider) => {
     setProvider(nextProvider);
     localStorage.setItem('selected-provider', nextProvider);
     setTimeout(() => textareaRef.current?.focus(), 100);
@@ -202,7 +203,7 @@ export default function ProviderSelectionEmptyState({
 
           {provider && tasksEnabled && isTaskMasterInstalled && (
             
- setInput('Start the next task')} onShowAllTasks={onShowAllTasks} /> + setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
)}
@@ -214,7 +215,7 @@ export default function ProviderSelectionEmptyState({ {tasksEnabled && isTaskMasterInstalled && (
- setInput('Start the next task')} onShowAllTasks={onShowAllTasks} /> + setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
)}
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 663c139..0db6d12 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -106,7 +106,9 @@ "enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands" }, "clickToChangeMode": "Click to change permission mode (or press Tab in input)", - "showAllCommands": "Show all commands" + "showAllCommands": "Show all commands", + "clearInput": "Clear input", + "scrollToBottom": "Scroll to bottom" }, "thinkingMode": { "selector": { @@ -201,5 +203,11 @@ "runCommand": "Run {{command}} in {{projectName}}", "startCli": "Starting Claude CLI in {{projectName}}", "defaultCommand": "command" + }, + "projectSelection": { + "startChatWithProvider": "Select a project to start chatting with {{provider}}" + }, + "tasks": { + "nextTaskPrompt": "Start the next task" } } diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 22fca56..0e76556 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -106,7 +106,9 @@ "enter": "Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어" }, "clickToChangeMode": "클릭하여 권한 모드 변경 (또는 입력창에서 Tab)", - "showAllCommands": "모든 명령어 보기" + "showAllCommands": "모든 명령어 보기", + "clearInput": "입력 지우기", + "scrollToBottom": "맨 아래로 스크롤" }, "thinkingMode": { "selector": { @@ -201,5 +203,11 @@ "runCommand": "{{projectName}}에서 {{command}} 실행", "startCli": "{{projectName}}에서 Claude CLI 시작", "defaultCommand": "명령어" + }, + "projectSelection": { + "startChatWithProvider": "{{provider}}와 채팅을 시작하려면 프로젝트를 선택하세요" + }, + "tasks": { + "nextTaskPrompt": "다음 작업 시작" } } diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 2d52ba4..0ad37e6 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -106,7 +106,9 @@ "enter": "Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令" }, "clickToChangeMode": "点击更改权限模式(或在输入框中按 Tab)", - "showAllCommands": "显示所有命令" + "showAllCommands": "显示所有命令", + "clearInput": "清空输入", + "scrollToBottom": "滚动到底部" }, "thinkingMode": { "selector": { @@ -201,5 +203,11 @@ "runCommand": "在 {{projectName}} 中运行 {{command}}", "startCli": "在 {{projectName}} 中启动 Claude CLI", "defaultCommand": "命令" + }, + "projectSelection": { + "startChatWithProvider": "选择一个项目以开始与 {{provider}} 聊天" + }, + "tasks": { + "nextTaskPrompt": "开始下一个任务" } }