mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 20:57:32 +00:00
fix(chat): stabilize provider/message handling and complete chat i18n coverage
Unify provider typing, harden realtime message effects, normalize tool input
serialization, and finish i18n/a11y updates across chat UI components.
- tighten provider contracts from `Provider | string` to `SessionProvider` in:
- `useChatProviderState`
- `useChatComposerState`
- `useChatRealtimeHandlers`
- `ChatMessagesPane`
- `ProviderSelectionEmptyState`
- refactor `AssistantThinkingIndicator` to accept `selectedProvider` via props
instead of reading provider from local storage during render
- fix stale-closure risk in `useChatRealtimeHandlers` by:
- adding missing effect dependencies
- introducing `lastProcessedMessageRef` to prevent duplicate processing when
dependencies change without a new message object
- standardize `toolInput` shape in `messageTransforms`:
- add `normalizeToolInput(...)`
- ensure all conversion paths produce consistent string output
- remove mixed `null`/raw/stringified variants across cursor/session branches
- harden tool display fallback in `CollapsibleDisplay`:
- default border class now falls back safely for unknown categories
- improve chat i18n consistency:
- localize hardcoded strings in `MessageComponent`
(`permissions.*`, `interactive.*`, `thinking.emoji`, `json.response`,
`messageTypes.error`)
- localize button titles in `ChatInputControls`
(`input.clearInput`, `input.scrollToBottom`)
- localize provider-specific empty-project prompt in `ChatInterface`
(`projectSelection.startChatWithProvider`)
- localize repeated “Start the next task” prompt in
`ProviderSelectionEmptyState` (`tasks.nextTaskPrompt`)
- add missing translation keys in all supported chat locales:
- `src/i18n/locales/en/chat.json`
- `src/i18n/locales/ko/chat.json`
- `src/i18n/locales/zh-CN/chat.json`
- new keys:
- `input.clearInput`
- `input.scrollToBottom`
- `projectSelection.startChatWithProvider`
- `tasks.nextTaskPrompt`
- improve attachment remove-button accessibility in `ImageAttachment`:
- add `type="button"` and `aria-label`
- make control visible on touch/small screens and focusable states
- preserve hover behavior on larger screens
Validation:
- `npm run typecheck`
This commit is contained in:
@@ -20,11 +20,10 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
PendingPermissionRequest,
|
PendingPermissionRequest,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
Provider,
|
|
||||||
} from '../types/types';
|
} from '../types/types';
|
||||||
import { useFileMentions } from './useFileMentions';
|
import { useFileMentions } from './useFileMentions';
|
||||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
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';
|
import { escapeRegExp } from '../utils/chatFormatting';
|
||||||
|
|
||||||
type PendingViewSession = {
|
type PendingViewSession = {
|
||||||
@@ -36,7 +35,7 @@ interface UseChatComposerStateArgs {
|
|||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
provider: Provider | string;
|
provider: SessionProvider;
|
||||||
permissionMode: PermissionMode | string;
|
permissionMode: PermissionMode | string;
|
||||||
cyclePermissionMode: () => void;
|
cyclePermissionMode: () => void;
|
||||||
cursorModel: string;
|
cursorModel: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
|
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
|
||||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
|
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
|
||||||
import type { ProjectSession } from '../../../types/app';
|
import type { ProjectSession, SessionProvider } from '../../../types/app';
|
||||||
|
|
||||||
interface UseChatProviderStateArgs {
|
interface UseChatProviderStateArgs {
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
@@ -11,8 +11,8 @@ interface UseChatProviderStateArgs {
|
|||||||
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
||||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
||||||
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
||||||
const [provider, setProvider] = useState<Provider>(() => {
|
const [provider, setProvider] = useState<SessionProvider>(() => {
|
||||||
return (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
|
||||||
});
|
});
|
||||||
const [cursorModel, setCursorModel] = useState<string>(() => {
|
const [cursorModel, setCursorModel] = useState<string>(() => {
|
||||||
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting';
|
import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting';
|
||||||
import { safeLocalStorage } from '../utils/chatStorage';
|
import { safeLocalStorage } from '../utils/chatStorage';
|
||||||
import type { ChatMessage, PendingPermissionRequest, Provider } from '../types/types';
|
import type { ChatMessage, PendingPermissionRequest } from '../types/types';
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||||
|
|
||||||
type PendingViewSession = {
|
type PendingViewSession = {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -28,7 +28,7 @@ type LatestChatMessage = {
|
|||||||
|
|
||||||
interface UseChatRealtimeHandlersArgs {
|
interface UseChatRealtimeHandlersArgs {
|
||||||
latestMessage: LatestChatMessage | null;
|
latestMessage: LatestChatMessage | null;
|
||||||
provider: Provider | string;
|
provider: SessionProvider;
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
@@ -114,11 +114,19 @@ export function useChatRealtimeHandlers({
|
|||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
|
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestMessage) {
|
if (!latestMessage) {
|
||||||
return;
|
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 messageData = latestMessage.data?.message || latestMessage.data;
|
||||||
const structuredMessageData =
|
const structuredMessageData =
|
||||||
messageData && typeof messageData === 'object' ? (messageData as Record<string, any>) : null;
|
messageData && typeof messageData === 'object' ? (messageData as Record<string, any>) : null;
|
||||||
@@ -925,5 +933,24 @@ export function useChatRealtimeHandlers({
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [latestMessage]);
|
}, [
|
||||||
|
latestMessage,
|
||||||
|
provider,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
currentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
setChatMessages,
|
||||||
|
setIsLoading,
|
||||||
|
setCanAbortSession,
|
||||||
|
setClaudeStatus,
|
||||||
|
setTokenBudget,
|
||||||
|
setIsSystemSessionChange,
|
||||||
|
setPendingPermissionRequests,
|
||||||
|
onSessionInactive,
|
||||||
|
onSessionProcessing,
|
||||||
|
onSessionNotProcessing,
|
||||||
|
onReplaceTemporarySession,
|
||||||
|
onNavigateToSession,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
toolCategory
|
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 (
|
return (
|
||||||
<div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}>
|
<div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}>
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ type CursorBlob = {
|
|||||||
|
|
||||||
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
|
const asArray = <T>(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) => {
|
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -149,7 +165,7 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
|
|||||||
isToolUse: true,
|
isToolUse: true,
|
||||||
toolName,
|
toolName,
|
||||||
toolId: toolCallId,
|
toolId: toolCallId,
|
||||||
toolInput: null,
|
toolInput: normalizeToolInput(null),
|
||||||
toolResult: {
|
toolResult: {
|
||||||
content: result,
|
content: result,
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -253,7 +269,7 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
|
|||||||
isToolUse: true,
|
isToolUse: true,
|
||||||
toolName,
|
toolName,
|
||||||
toolId,
|
toolId,
|
||||||
toolInput: toolInput ? JSON.stringify(toolInput) : null,
|
toolInput: normalizeToolInput(toolInput),
|
||||||
toolResult: null,
|
toolResult: null,
|
||||||
};
|
};
|
||||||
converted.push(toolMessage);
|
converted.push(toolMessage);
|
||||||
@@ -412,7 +428,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
|||||||
timestamp: message.timestamp || new Date().toISOString(),
|
timestamp: message.timestamp || new Date().toISOString(),
|
||||||
isToolUse: true,
|
isToolUse: true,
|
||||||
toolName: message.toolName,
|
toolName: message.toolName,
|
||||||
toolInput: message.toolInput || '',
|
toolInput: normalizeToolInput(message.toolInput),
|
||||||
toolCallId: message.toolCallId,
|
toolCallId: message.toolCallId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -459,7 +475,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
|||||||
timestamp: message.timestamp || new Date().toISOString(),
|
timestamp: message.timestamp || new Date().toISOString(),
|
||||||
isToolUse: true,
|
isToolUse: true,
|
||||||
toolName: part.name,
|
toolName: part.name,
|
||||||
toolInput: JSON.stringify(part.input),
|
toolInput: normalizeToolInput(part.input),
|
||||||
toolResult: toolResult
|
toolResult: toolResult
|
||||||
? {
|
? {
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -245,10 +245,22 @@ function ChatInterface({
|
|||||||
}, [resetStreamingState]);
|
}, [resetStreamingState]);
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
|
const selectedProviderLabel =
|
||||||
|
provider === 'cursor'
|
||||||
|
? t('messageTypes.cursor')
|
||||||
|
: provider === 'codex'
|
||||||
|
? t('messageTypes.codex')
|
||||||
|
: t('messageTypes.claude');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||||
<p>Select a project to start chatting with Claude</p>
|
<p>
|
||||||
|
{t('projectSelection.startChatWithProvider', {
|
||||||
|
provider: selectedProviderLabel,
|
||||||
|
defaultValue: 'Select a project to start chatting with {{provider}}',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
import SessionProviderLogo from "../../../SessionProviderLogo";
|
import { SessionProvider } from '../../../../types/app';
|
||||||
import { Provider } from "../../types/types";
|
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||||
|
import type { Provider } from '../../types/types';
|
||||||
|
|
||||||
export default function AssistantThinkingIndicator() {
|
type AssistantThinkingIndicatorProps = {
|
||||||
const selectedProvider = (localStorage.getItem('selected-provider') || 'claude') as Provider;
|
selectedProvider: SessionProvider;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="chat-message assistant">
|
export default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) {
|
||||||
<div className="w-full">
|
return (
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="chat-message assistant">
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
|
<div className="w-full">
|
||||||
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
</div>
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
|
||||||
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
|
</div>
|
||||||
</div>
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
</div>
|
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
|
||||||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<div className="animate-pulse">.</div>
|
|
||||||
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
<span className="ml-2">Thinking...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
||||||
}
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className="animate-pulse">.</div>
|
||||||
|
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
<span className="ml-2">Thinking...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function ChatInputControls({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClearInput}
|
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"
|
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' })}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-gray-800 dark:group-hover:text-gray-100 transition-colors"
|
className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-gray-800 dark:group-hover:text-gray-100 transition-colors"
|
||||||
@@ -126,7 +126,7 @@ export default function ChatInputControls({
|
|||||||
<button
|
<button
|
||||||
onClick={onScrollToBottom}
|
onClick={onScrollToBottom}
|
||||||
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
|
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
|
||||||
title="Scroll to bottom"
|
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { Dispatch, RefObject, SetStateAction } from 'react';
|
|||||||
|
|
||||||
import MessageComponent from './MessageComponent';
|
import MessageComponent from './MessageComponent';
|
||||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||||
import type { ChatMessage, Provider } from '../../types/types';
|
import type { ChatMessage } from '../../types/types';
|
||||||
import type { Project, ProjectSession } from '../../../../types/app';
|
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
|
||||||
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
|
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
|
||||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ interface ChatMessagesPaneProps {
|
|||||||
chatMessages: ChatMessage[];
|
chatMessages: ChatMessage[];
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
provider: Provider | string;
|
provider: SessionProvider;
|
||||||
setProvider: (provider: Provider | string) => void;
|
setProvider: (provider: SessionProvider) => void;
|
||||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||||
claudeModel: string;
|
claudeModel: string;
|
||||||
setClaudeModel: (model: string) => void;
|
setClaudeModel: (model: string) => void;
|
||||||
@@ -201,7 +201,7 @@ export default function ChatMessagesPane({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <AssistantThinkingIndicator />}
|
{isLoading && <AssistantThinkingIndicator selectedProvider={provider} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
|
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||||
|
aria-label="Remove image"
|
||||||
>
|
>
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
<svg className="w-4 h-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs font-medium text-red-700 dark:text-red-300">Error</span>
|
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative text-sm text-red-900 dark:text-red-100">
|
<div className="relative text-sm text-red-900 dark:text-red-100">
|
||||||
<Markdown className="prose prose-sm max-w-none prose-red dark:prose-invert">
|
<Markdown className="prose prose-sm max-w-none prose-red dark:prose-invert">
|
||||||
@@ -214,8 +214,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||||
? 'Permission added'
|
? t('permissions.added')
|
||||||
: `Grant permission for ${permissionSuggestion.toolName}`}
|
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
||||||
</button>
|
</button>
|
||||||
{onShowSettings && (
|
{onShowSettings && (
|
||||||
<button
|
<button
|
||||||
@@ -223,21 +223,21 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
||||||
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
|
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
|
||||||
>
|
>
|
||||||
Open settings
|
{t('permissions.openSettings')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||||
Adds <span className="font-mono">{permissionSuggestion.entry}</span> to Allowed Tools.
|
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
||||||
</div>
|
</div>
|
||||||
{permissionGrantState === 'error' && (
|
{permissionGrantState === 'error' && (
|
||||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||||
Unable to update permissions. Please try again.
|
{t('permissions.error')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||||
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||||
Permission saved. Retry the request to use the tool.
|
{t('permissions.retry')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +273,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3">
|
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3">
|
||||||
Interactive Prompt
|
{t('interactive.title')}
|
||||||
</h4>
|
</h4>
|
||||||
{(() => {
|
{(() => {
|
||||||
const lines = (message.content || '').split('\n').filter((line) => line.trim());
|
const lines = (message.content || '').split('\n').filter((line) => line.trim());
|
||||||
@@ -333,10 +333,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
|
|
||||||
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
||||||
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
||||||
⏳ Waiting for your response in the CLI
|
{t('interactive.waiting')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-amber-800 dark:text-amber-200 text-xs">
|
<p className="text-amber-800 dark:text-amber-200 text-xs">
|
||||||
Please select an option in your terminal where Claude is running.
|
{t('interactive.instruction')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -353,7 +353,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>💭 Thinking...</span>
|
<span>{t('thinking.emoji')}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm">
|
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm">
|
||||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
|
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
|
||||||
@@ -368,7 +368,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
{showThinking && message.reasoning && (
|
{showThinking && message.reasoning && (
|
||||||
<details className="mb-3">
|
<details className="mb-3">
|
||||||
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
|
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
|
||||||
💭 Thinking...
|
{t('thinking.emoji')}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm">
|
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm">
|
||||||
<div className="whitespace-pre-wrap">
|
<div className="whitespace-pre-wrap">
|
||||||
@@ -395,7 +395,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">JSON Response</span>
|
<span className="font-medium">{t('json.response')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<pre className="p-4 overflow-x-auto">
|
<pre className="p-4 overflow-x-auto">
|
||||||
@@ -437,4 +437,5 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MessageComponent;
|
export default MessageComponent;
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||||
import NextTaskBanner from '../../../NextTaskBanner.jsx';
|
import NextTaskBanner from '../../../NextTaskBanner.jsx';
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
|
||||||
import type { Provider } from '../../types/types';
|
import type { ProjectSession, SessionProvider } from '../../../../types/app';
|
||||||
import type { ProjectSession } from '../../../../types/app';
|
|
||||||
|
|
||||||
interface ProviderSelectionEmptyStateProps {
|
interface ProviderSelectionEmptyStateProps {
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
provider: Provider | string;
|
provider: SessionProvider;
|
||||||
setProvider: (next: Provider | string) => void;
|
setProvider: (next: SessionProvider) => void;
|
||||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
claudeModel: string;
|
claudeModel: string;
|
||||||
setClaudeModel: (model: string) => void;
|
setClaudeModel: (model: string) => void;
|
||||||
@@ -42,8 +41,10 @@ export default function ProviderSelectionEmptyState({
|
|||||||
setInput,
|
setInput,
|
||||||
}: ProviderSelectionEmptyStateProps) {
|
}: ProviderSelectionEmptyStateProps) {
|
||||||
const { t } = useTranslation('chat');
|
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);
|
setProvider(nextProvider);
|
||||||
localStorage.setItem('selected-provider', nextProvider);
|
localStorage.setItem('selected-provider', nextProvider);
|
||||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||||
@@ -202,7 +203,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
|
|
||||||
{provider && tasksEnabled && isTaskMasterInstalled && (
|
{provider && tasksEnabled && isTaskMasterInstalled && (
|
||||||
<div className="mt-4 px-4 sm:px-0">
|
<div className="mt-4 px-4 sm:px-0">
|
||||||
<NextTaskBanner onStartTask={() => setInput('Start the next task')} onShowAllTasks={onShowAllTasks} />
|
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +215,7 @@ export default function ProviderSelectionEmptyState({
|
|||||||
|
|
||||||
{tasksEnabled && isTaskMasterInstalled && (
|
{tasksEnabled && isTaskMasterInstalled && (
|
||||||
<div className="mt-4 px-4 sm:px-0">
|
<div className="mt-4 px-4 sm:px-0">
|
||||||
<NextTaskBanner onStartTask={() => setInput('Start the next task')} onShowAllTasks={onShowAllTasks} />
|
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,7 +106,9 @@
|
|||||||
"enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
"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)",
|
"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": {
|
"thinkingMode": {
|
||||||
"selector": {
|
"selector": {
|
||||||
@@ -201,5 +203,11 @@
|
|||||||
"runCommand": "Run {{command}} in {{projectName}}",
|
"runCommand": "Run {{command}} in {{projectName}}",
|
||||||
"startCli": "Starting Claude CLI in {{projectName}}",
|
"startCli": "Starting Claude CLI in {{projectName}}",
|
||||||
"defaultCommand": "command"
|
"defaultCommand": "command"
|
||||||
|
},
|
||||||
|
"projectSelection": {
|
||||||
|
"startChatWithProvider": "Select a project to start chatting with {{provider}}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"nextTaskPrompt": "Start the next task"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,9 @@
|
|||||||
"enter": "Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어"
|
"enter": "Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어"
|
||||||
},
|
},
|
||||||
"clickToChangeMode": "클릭하여 권한 모드 변경 (또는 입력창에서 Tab)",
|
"clickToChangeMode": "클릭하여 권한 모드 변경 (또는 입력창에서 Tab)",
|
||||||
"showAllCommands": "모든 명령어 보기"
|
"showAllCommands": "모든 명령어 보기",
|
||||||
|
"clearInput": "입력 지우기",
|
||||||
|
"scrollToBottom": "맨 아래로 스크롤"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
"thinkingMode": {
|
||||||
"selector": {
|
"selector": {
|
||||||
@@ -201,5 +203,11 @@
|
|||||||
"runCommand": "{{projectName}}에서 {{command}} 실행",
|
"runCommand": "{{projectName}}에서 {{command}} 실행",
|
||||||
"startCli": "{{projectName}}에서 Claude CLI 시작",
|
"startCli": "{{projectName}}에서 Claude CLI 시작",
|
||||||
"defaultCommand": "명령어"
|
"defaultCommand": "명령어"
|
||||||
|
},
|
||||||
|
"projectSelection": {
|
||||||
|
"startChatWithProvider": "{{provider}}와 채팅을 시작하려면 프로젝트를 선택하세요"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"nextTaskPrompt": "다음 작업 시작"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,9 @@
|
|||||||
"enter": "Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令"
|
"enter": "Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令"
|
||||||
},
|
},
|
||||||
"clickToChangeMode": "点击更改权限模式(或在输入框中按 Tab)",
|
"clickToChangeMode": "点击更改权限模式(或在输入框中按 Tab)",
|
||||||
"showAllCommands": "显示所有命令"
|
"showAllCommands": "显示所有命令",
|
||||||
|
"clearInput": "清空输入",
|
||||||
|
"scrollToBottom": "滚动到底部"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
"thinkingMode": {
|
||||||
"selector": {
|
"selector": {
|
||||||
@@ -201,5 +203,11 @@
|
|||||||
"runCommand": "在 {{projectName}} 中运行 {{command}}",
|
"runCommand": "在 {{projectName}} 中运行 {{command}}",
|
||||||
"startCli": "在 {{projectName}} 中启动 Claude CLI",
|
"startCli": "在 {{projectName}} 中启动 Claude CLI",
|
||||||
"defaultCommand": "命令"
|
"defaultCommand": "命令"
|
||||||
|
},
|
||||||
|
"projectSelection": {
|
||||||
|
"startChatWithProvider": "选择一个项目以开始与 {{provider}} 聊天"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"nextTaskPrompt": "开始下一个任务"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user