Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/__tests__/unit/use-sse-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { consumeSSEStream, formatSSEError } from '../../hooks/useSSEStream';

describe('formatSSEError', () => {
const originalNavigator = globalThis.navigator;

afterEach(() => {
if (originalNavigator) {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true,
});
} else {
Reflect.deleteProperty(globalThis, 'navigator');
}
});

it('should localize structured working directory errors with provided translator', () => {
const formatted = formatSSEError(
JSON.stringify({
error_code: 'WORKING_DIR_NOT_FOUND',
working_directory: '/tmp/demo',
}),
(key, params) => `${key}:${params?.dir}`
);

assert.equal(formatted, 'error.workingDirNotFound:/tmp/demo');
});

it('should fall back to locale detection when translator is omitted', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { language: 'zh-CN' },
configurable: true,
});

const formatted = formatSSEError(
JSON.stringify({
error_code: 'WORKING_DIR_NOT_FOUND',
working_directory: '/tmp/demo',
})
);

assert.equal(formatted, '项目目录不存在:/tmp/demo');
});

it('should return plain error text unchanged', () => {
assert.equal(formatSSEError('plain error text'), 'plain error text');
});
});

describe('consumeSSEStream', () => {
it('should localize structured error events in the full consume path', async () => {
const encoder = new TextEncoder();
let errorText = '';

const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'error',
data: JSON.stringify({
error_code: 'WORKING_DIR_NOT_FOUND',
working_directory: '/tmp/project',
}),
})}\n\n`
)
);
controller.close();
},
});

const result = await consumeSSEStream(stream.getReader(), {
onText: () => {},
onToolUse: () => {},
onToolResult: () => {},
onToolOutput: () => {},
onToolProgress: () => {},
onStatus: () => {},
onResult: () => {},
onPermissionRequest: () => {},
onToolTimeout: () => {},
onModeChanged: () => {},
onTaskUpdate: () => {},
onKeepAlive: () => {},
onError: (accumulated) => {
errorText = accumulated;
},
}, (key, params) => `${key}:${params?.dir}`);

assert.equal(errorText, '\n\n**Error:** error.workingDirNotFound:/tmp/project');
assert.equal(result.accumulated, '\n\n**Error:** error.workingDirNotFound:/tmp/project');
});
});
11 changes: 7 additions & 4 deletions src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestE
import { MessageList } from '@/components/chat/MessageList';
import { MessageInput } from '@/components/chat/MessageInput';
import { usePanel } from '@/hooks/usePanel';
import { formatSSEError } from '@/hooks/useSSEStream';
import { useTranslation } from '@/hooks/useTranslation';

interface ToolUseInfo {
id: string;
Expand All @@ -20,14 +22,15 @@ interface ToolResultInfo {

export default function NewChatPage() {
const router = useRouter();
const { setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const { setPendingApprovalSessionId } = usePanel();
const { t } = useTranslation();
const [messages, setMessages] = useState<Message[]>([]);
const [streamingContent, setStreamingContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [toolUses, setToolUses] = useState<ToolUseInfo[]>([]);
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
const [statusText, setStatusText] = useState<string | undefined>();
const [workingDir, setWorkingDir] = useState('');
const [workingDir] = useState('');
const [mode, setMode] = useState('code');
const [currentModel, setCurrentModel] = useState('sonnet');
const [currentProviderId, setCurrentProviderId] = useState('');
Expand Down Expand Up @@ -252,7 +255,7 @@ export default function NewChatPage() {
break;
}
case 'error': {
accumulated += '\n\n**Error:** ' + event.data;
accumulated += '\n\n**Error:** ' + formatSSEError(event.data, t);
setStreamingContent(accumulated);
break;
}
Expand Down Expand Up @@ -311,7 +314,7 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
},
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, setPendingApprovalSessionId]
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, setPendingApprovalSessionId, t]
);

const handleCommand = useCallback((command: string) => {
Expand Down
6 changes: 5 additions & 1 deletion src/components/layout/I18nProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { type Locale, type TranslationKey, translate } from '@/i18n';
import { type Locale, type TranslationKey, translate, setCurrentLocale } from '@/i18n';

interface I18nContextValue {
locale: Locale;
Expand Down Expand Up @@ -37,6 +37,10 @@ export function I18nProvider({ children }: { children: ReactNode }) {
loadLocale();
}, []);

useEffect(() => {
setCurrentLocale(locale);
}, [locale]);

const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
// Persist to app settings
Expand Down
27 changes: 24 additions & 3 deletions src/hooks/useSSEStream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { useRef, useCallback } from 'react';
import type { SSEEvent, TokenUsage, PermissionRequestEvent } from '@/types';
import { translate, detectLocale, type TranslationKey } from '@/i18n';

export type TranslationFn = (key: TranslationKey, params?: Record<string, string | number>) => string;

export function formatSSEError(eventData: string, t?: TranslationFn): string {
try {
const parsed = JSON.parse(eventData);
if (parsed.error_code === 'WORKING_DIR_NOT_FOUND') {
const translator = t ?? ((key: TranslationKey, params?: Record<string, string | number>) =>
translate(detectLocale(), key, params));
return translator('error.workingDirNotFound', { dir: parsed.working_directory });
}
} catch {
// ignore
}

return eventData;
}

interface ToolUseInfo {
id: string;
Expand Down Expand Up @@ -36,6 +54,7 @@ function handleSSEEvent(
event: SSEEvent,
accumulated: string,
callbacks: SSECallbacks,
t?: TranslationFn,
): string {
switch (event.type) {
case 'text': {
Expand Down Expand Up @@ -153,7 +172,7 @@ function handleSSEEvent(
}

case 'error': {
const next = accumulated + '\n\n**Error:** ' + event.data;
const next = accumulated + '\n\n**Error:** ' + formatSSEError(event.data, t);
callbacks.onError(next);
return next;
}
Expand All @@ -174,6 +193,7 @@ function handleSSEEvent(
export async function consumeSSEStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
callbacks: SSECallbacks,
t?: TranslationFn,
): Promise<{ accumulated: string; tokenUsage: TokenUsage | null }> {
const decoder = new TextDecoder();
let buffer = '';
Expand Down Expand Up @@ -201,7 +221,7 @@ export async function consumeSSEStream(

try {
const event: SSEEvent = JSON.parse(line.slice(6));
accumulated = handleSSEEvent(event, accumulated, wrappedCallbacks);
accumulated = handleSSEEvent(event, accumulated, wrappedCallbacks, t);
} catch {
// skip malformed SSE lines
}
Expand All @@ -222,6 +242,7 @@ export function useSSEStream() {
async (
reader: ReadableStreamDefaultReader<Uint8Array>,
callbacks: SSECallbacks,
t?: TranslationFn,
) => {
callbacksRef.current = callbacks;

Expand All @@ -242,7 +263,7 @@ export function useSSEStream() {
onError: (a) => callbacksRef.current?.onError(a),
};

return consumeSSEStream(reader, proxied);
return consumeSSEStream(reader, proxied, t);
},
[],
);
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ const en = {
'error.hideDetails': 'Hide details',
'error.tryAgain': 'Try Again',
'error.reloadApp': 'Reload App',
'error.workingDirNotFound': 'Project directory not found: {dir}',

// ── Update ─────────────────────────────────────────────────
'update.newVersionAvailable': 'New Version Available',
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export type { TranslationKey };

export type Locale = 'en' | 'zh';

let currentLocale: Locale | null = null;

export const SUPPORTED_LOCALES: { value: Locale; label: string }[] = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
Expand All @@ -15,6 +17,16 @@ const dictionaries: Record<Locale, Record<TranslationKey, string>> = {
zh,
};

export function setCurrentLocale(locale: Locale): void {
currentLocale = locale;
}

export function detectLocale(): Locale {
if (currentLocale) return currentLocale;
if (typeof navigator !== 'undefined' && navigator.language.startsWith('zh')) return 'zh';
return 'en';
}

/**
* Translate a key with optional parameter interpolation.
* Fallback chain: target locale → English → raw key.
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ const zh: Record<TranslationKey, string> = {
'error.hideDetails': '隐藏详情',
'error.tryAgain': '重试',
'error.reloadApp': '重新加载',
'error.workingDirNotFound': '项目目录不存在:{dir}',

// ── Update ─────────────────────────────────────────────────
'update.newVersionAvailable': '有新版本可用',
Expand Down
45 changes: 23 additions & 22 deletions src/lib/claude-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { query } from '@anthropic-ai/claude-agent-sdk';
import type {
SDKMessage,
SDKAssistantMessage,
SDKUserMessage,
SDKResultMessage,
Expand Down Expand Up @@ -31,7 +30,6 @@ import path from 'path';
* Removes null bytes and control characters that cause spawn EINVAL.
*/
function sanitizeEnvValue(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}

Expand Down Expand Up @@ -165,19 +163,6 @@ function formatSSE(event: SSEEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}

/**
* Extract text content from an SDK assistant message
*/
function extractTextFromMessage(msg: SDKAssistantMessage): string {
const parts: string[] = [];
for (const block of msg.message.content) {
if (block.type === 'text') {
parts.push(block.text);
}
}
return parts.join('');
}

/**
* Extract token usage from an SDK result message
*/
Expand Down Expand Up @@ -363,6 +348,20 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
}
}

if (workingDirectory && !fs.existsSync(workingDirectory)) {
controller.enqueue(formatSSE({
type: 'error',
data: JSON.stringify({
error_code: 'WORKING_DIR_NOT_FOUND',
message: `Project directory not found: ${workingDirectory}`,
working_directory: workingDirectory,
}),
}));
controller.enqueue(formatSSE({ type: 'done', data: '' }));
controller.close();
return;
}

// Check if dangerously_skip_permissions is enabled in app settings
const skipPermissions = getSetting('dangerously_skip_permissions') === 'true';

Expand Down Expand Up @@ -730,7 +729,6 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin

registerConversation(sessionId, conversation);

let lastAssistantText = '';
let tokenUsage: TokenUsage | null = null;

for await (const message of conversation) {
Expand All @@ -742,11 +740,6 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
case 'assistant': {
const assistantMsg = message as SDKAssistantMessage;
// Text deltas are handled by stream_event for real-time streaming.
// Only track lastAssistantText here and process tool_use blocks.
const text = extractTextFromMessage(assistantMsg);
if (text) {
lastAssistantText = text;
}

// Check for tool use blocks
for (const block of assistantMsg.message.content) {
Expand Down Expand Up @@ -906,7 +899,15 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
if (error instanceof Error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || rawMessage.includes('ENOENT') || rawMessage.includes('spawn')) {
errorMessage = `Claude Code CLI not found. Please ensure Claude Code is installed and available in your PATH.\n\nOriginal error: ${rawMessage}`;
if (workingDirectory && !fs.existsSync(workingDirectory)) {
errorMessage = JSON.stringify({
error_code: 'WORKING_DIR_NOT_FOUND',
message: `Project directory not found: ${workingDirectory}`,
working_directory: workingDirectory,
});
} else {
errorMessage = `Claude Code CLI not found. Please ensure Claude Code is installed and available in your PATH.\n\nOriginal error: ${rawMessage}`;
}
} else if (rawMessage.includes('exited with code 1') || rawMessage.includes('exit code 1')) {
const providerHint = activeProvider?.name ? ` (Provider: ${activeProvider.name})` : '';
const detailHint = extraDetail ? `\n\nDetails: ${extraDetail}` : '';
Expand Down
Loading