Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { useTranslation } from 'react-i18next';
import { createLogger } from '@/shared/utils/logger';
import { Modal, Button, Input, Tooltip } from '@/component-library';
import './NewProjectDialog.scss';
import {workspaceAPI} from "@/infrastructure";
import {workspaceAPI, systemAPI} from "@/infrastructure";
import { isTauriCommandError } from '@/infrastructure/api/errors/TauriCommandError';
import { notificationService } from '@/shared/notification-system';

const log = createLogger('NewProjectDialog');
Expand Down Expand Up @@ -81,6 +82,25 @@ export const NewProjectDialog: React.FC<NewProjectDialogProps> = ({
notificationService.warning(t('newProject.errorInvalidName'), { duration: 4500 });
return;
}
if (projectName.trim().length > 255) {
setError(t('newProject.errorNameTooLong'));
return;
}

// Pre-creation existence / case-collision check. On Windows/macOS the
// filesystem is case-insensitive, so "MyProject" and "myproject" resolve to
// the same folder; createDirectory is idempotent and would silently succeed
// without creating a new folder. Surface a clear error before attempting.
const trimmedName = projectName.trim();
const fullPath = `${parentPath.replace(/\\/g, '/')}/${trimmedName}`;
try {
if (await systemAPI.checkPathExists(fullPath)) {
setError(t('newProject.errorAlreadyExists'));
return;
}
} catch (error) {
log.error('Failed to check path existence', error);
}

setIsCreating(true);
setError('');
Expand All @@ -92,7 +112,14 @@ export const NewProjectDialog: React.FC<NewProjectDialogProps> = ({
onClose();
} catch (error) {
log.error('Failed to create project', error);
const message = error instanceof Error && error.message ? error.message : t('newProject.errorCreateFailed');
let message: string;
if (isTauriCommandError(error) && error.isPermissionError()) {
message = t('newProject.errorParentNoAccess');
} else if (error instanceof Error && /does not exist|not a directory/i.test(error.message)) {
message = t('newProject.errorPathNotFound');
} else {
message = t('newProject.errorCreateFailed');
}
setError(message);
notificationService.error(message, { duration: 4500 });
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,9 @@ function reviewSessionLifecycle(session: Session): LinkedReviewSession['lifecycl
}

function getSessionTitle(session?: Session, fallback = 'Review session'): string {
if (session?.titleSource === 'i18n' && session.titleI18nKey) {
return i18nService.t(session.titleI18nKey, session.titleI18nParams ?? {}) || fallback;
}
return session?.title?.trim() || fallback;
}

Expand Down Expand Up @@ -1852,7 +1855,9 @@ export const ReviewPlatformPanel: React.FC<ReviewPlatformPanelProps> = ({
workspacePath: linked.childSession.workspacePath,
expand: true,
sessionKind: linked.kind,
sessionTitle: linked.title,
sessionTitle: linked.childSession.titleSource === 'i18n' && linked.childSession.titleI18nKey
? (i18nService.t(linked.childSession.titleI18nKey, linked.childSession.titleI18nParams ?? {}) || linked.title)
: linked.title,
agentType: linked.childSession.config.agentType ?? (linked.kind === 'deep_review' ? 'DeepReview' : 'CodeReview'),
});
};
Expand Down
14 changes: 10 additions & 4 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
import { ArrowUp, BotMessageSquare, Image, RotateCcw, Plus, X, Sparkles, Loader2, ChevronRight, Files, MessageSquarePlus, Star } from 'lucide-react';
import { ContextDropZone, useContextStore } from '../../shared/context-system';
import { useActiveSessionState } from '@/flow_chat/hooks';
import { i18nService } from '@/infrastructure/i18n';
import { resolveSessionTitle } from '../utils/sessionTitle';
import {
RichTextInput,
type InlineTriggerState,
Expand Down Expand Up @@ -476,7 +478,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const threadGoalController = useThreadGoalController(effectiveTargetSession, {
isBtwSession,
});
const currentSessionTitle = currentSession?.title?.trim() || t('session.untitled');
// Resolve via the i18n key (re-localizes on locale switch) instead of the
// persisted localized session.title string, so this tab stays in sync with
// the sidebar/header/toolbar instead of showing a stale old-locale name.
const currentSessionTitle = currentSession
? resolveSessionTitle(currentSession, (key, options) => i18nService.t(key, options))
: t('session.untitled');
const activeBtwSession = activeBtwSessionId
? flowChatState.sessions.get(activeBtwSessionId)
: undefined;
Expand All @@ -492,10 +499,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const activeBtwTargetLabel = t(`childSession.kinds.${activeBtwKind}.short`, {
defaultValue: t('chatInput.targetBtw'),
});
const activeBtwFallbackKey = `childSession.kinds.${activeBtwKind}.title`;
const activeBtwSessionTitle = activeBtwSession
? activeBtwSession.title?.trim() || t(`childSession.kinds.${activeBtwKind}.title`, {
defaultValue: t('btw.threadLabel'),
})
? resolveSessionTitle(activeBtwSession, (key, options) => i18nService.t(key, options), activeBtwFallbackKey) || t('btw.threadLabel')
: '';

const deferChatStripPassiveGitRefresh =
Expand Down
9 changes: 7 additions & 2 deletions src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import { i18nService } from '@/infrastructure/i18n';
import path from 'path-browserify';
import {CornerUpLeft, Link2, Square, Sparkles} from 'lucide-react';
import {FlowChatContext} from '../modern/FlowChatContext';
Expand Down Expand Up @@ -83,8 +84,12 @@ const PANEL_CONFIG: FlowChatConfig = {
theme: 'dark',
};

const resolveSessionTitle = (session?: Session | null, fallback = 'Side thread') =>
session?.title?.trim() || fallback;
const resolveSessionTitle = (session?: Session | null, fallback = 'Side thread') => {
if (session?.titleSource === 'i18n' && session.titleI18nKey) {
return i18nService.t(session.titleI18nKey, session.titleI18nParams ?? {}) || fallback;
}
return session?.title?.trim() || fallback;
};
const log = createLogger('BtwSessionPanel');
const REVIEW_ACTION_BOTTOM_BLANK_SPACE_PX = 96;
const EMPTY_ACTION_ID_SET = new Set<string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,12 @@ export const ExportImageButton: React.FC<ExportImageButtonProps> = ({

for (const [, session] of state.sessions) {
const turn = session.dialogTurns.find((t: DialogTurn) => t.id === turnId);
if (turn) return { turn, sessionTitle: session.title?.trim() || '' };
if (turn) {
const sessionTitle = session.titleSource === 'i18n' && session.titleI18nKey
? (i18nService.t(session.titleI18nKey, session.titleI18nParams ?? {}) || '')
: (session.title?.trim() || '');
return { turn, sessionTitle };
}
}
return null;
}, [turnId]);
Expand Down
10 changes: 6 additions & 4 deletions src/web-ui/src/flow_chat/services/FlowChatManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,12 @@ export class FlowChatManager {
nextCursor = nextPage.nextCursor;
}
}
const hasHistoricalSessions =
workspaceSessions.length > 0 ||
initialMetadataPage.totalTopLevelCount > 0 ||
initialMetadataPage.sessions.length > 0;
// Count only sessions that belong to THIS workspace (already path-filtered
// above). Counting all sessions in the storage dir (totalTopLevelCount /
// initialMetadataPage.sessions.length) incorrectly reports "has history"
// when another workspace shares the slug-collided sessions/ dir, which
// suppresses default-session creation for a newly opened workspace.
const hasHistoricalSessions = workspaceSessions.length > 0;
const isCurrentInitializationRequest = () =>
this.latestInitializationRequestKey === requestKey;
const activeSession = state.activeSessionId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI';
import { configManager } from '@/infrastructure/config/services/ConfigManager';
import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types';
import { notificationService } from '../../../shared/notification-system';
import { i18nService } from '@/infrastructure/i18n';
import { stateMachineManager } from '../../state-machine';
import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types';
import { generateTempTitle } from '../../utils/titleUtils';
Expand Down Expand Up @@ -358,30 +359,36 @@ export async function sendMessage(

} catch (error) {
log.error('Failed to send message', { sessionId: sessionId, error });

const errorMessage = error instanceof Error ? error.message : 'Failed to send message';


// Map the "workspace folder deleted/moved" backend error to a localized
// message; other errors pass through verbatim for diagnostics. The
// "Thinking process error" notification title was also hardcoded English.
const rawErrorMessage = error instanceof Error ? error.message : '';
const errorMessage = /does not resolve to a local workspace/i.test(rawErrorMessage)
? i18nService.t('flow-chat:errors.workspaceNotResolvable')
: (rawErrorMessage || i18nService.t('flow-chat:errors.sendFailed'));

const currentState = stateMachineManager.getCurrentState(sessionId);
if (currentState === SessionExecutionState.PROCESSING) {
await stateMachineManager.transition(sessionId, SessionExecutionEvent.ERROR_OCCURRED, {
error: errorMessage
});
await stateMachineManager.transition(sessionId, SessionExecutionEvent.RESET);
}

const state = context.flowChatStore.getState();
const currentSession = state.sessions.get(sessionId);
if (createdLocalTurnId && currentSession && !options?.preserveTurnOnStartError) {
context.flowChatStore.deleteDialogTurn(sessionId, createdLocalTurnId);
}

if (!options?.preserveTurnOnStartError) {
notificationService.error(errorMessage, {
title: 'Thinking process error',
title: i18nService.t('flow-chat:errors.thinkingProcessError'),
duration: 5000
});
}

throw error;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types';
import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types';
import type { FlowChatContext, SessionConfig } from './types';
import type { Session } from '../../types/flow-chat';
import { touchSessionActivity, cleanupSaveState } from './PersistenceModule';
import { touchSessionActivity, cleanupSaveState, updateSessionMetadata } from './PersistenceModule';
import { cleanupSessionBuffers } from './TextChunkModule';
import {
createTextSessionTitleDescriptor,
Expand Down Expand Up @@ -643,6 +643,12 @@ export async function createChatSession(
titleDescriptor,
);

// Persist the title metadata (incl. the i18n title key/params) at creation
// so an empty session can re-localize its default name on a later reload +
// locale switch. createSession only stored the localized sessionName string;
// without this, empty sessions have no title key on disk and can't re-localize.
await updateSessionMetadata(context, response.sessionId);

return response.sessionId;
});

Expand Down
8 changes: 5 additions & 3 deletions src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ export class TauriCommandError extends Error {

public isPermissionError(): boolean {
const message = this.message.toLowerCase();
return message.includes('permission') ||
message.includes('access') ||
return message.includes('permission') ||
message.includes('permitted') ||
message.includes('access') ||
message.includes('unauthorized') ||
message.includes('forbidden');
message.includes('forbidden') ||
message.includes('eperm');
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createLogger } from '@/shared/utils/logger';
import { startupTrace } from '@/shared/utils/startupTrace';
import { elapsedMs, nowMs } from '@/shared/utils/timing';
import { listen } from '@tauri-apps/api/event';
import { i18nService } from '@/infrastructure/i18n';

const log = createLogger('WorkspaceManager');

Expand Down Expand Up @@ -625,7 +626,12 @@ class WorkspaceManager {
return workspace;
} catch (error) {
log.error('Failed to open workspace', { path, error });
const errorMessage = error instanceof Error ? error.message : String(error);
const rawMessage = error instanceof Error ? error.message : String(error);
// Map the common "folder moved/deleted" backend error to a localized
// message; other errors pass through verbatim (callers log the raw error).
const errorMessage = /does not exist|not a directory/i.test(rawMessage)
? i18nService.t('common:newProject.errorPathNotFound')
: rawMessage;
this.updateState({ loading: false, error: errorMessage }, { type: 'workspace:error', error: errorMessage });
throw error;
}
Expand Down
6 changes: 5 additions & 1 deletion src/web-ui/src/locales/en-US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,11 @@
"errorSelectParent": "Please select a parent directory",
"errorEnterName": "Please enter a project name",
"errorCreateFailed": "Failed to create project",
"errorInvalidName": "Project name cannot contain these characters: / \\ : * ? \" < > |"
"errorInvalidName": "Project name cannot contain these characters: / \\ : * ? \" < > |",
"errorAlreadyExists": "A workspace with this name already exists at the selected location",
"errorParentNoAccess": "No access permission for the selected parent directory",
"errorNameTooLong": "Project name is too long (max 255 characters)",
"errorPathNotFound": "The workspace folder does not exist or has been moved"
},
"peerDirectoryPicker": {
"loading": "Loading remote directories…",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2356,7 +2356,9 @@
}
},
"errors": {
"sendFailed": "Failed to send message"
"sendFailed": "Failed to send message",
"thinkingProcessError": "Thinking process error",
"workspaceNotResolvable": "The workspace folder does not exist or has been moved"
},
"actions": {
"send": "Send"
Expand Down
6 changes: 5 additions & 1 deletion src/web-ui/src/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,11 @@
"errorSelectParent": "请选择父目录",
"errorEnterName": "请输入工作区名称",
"errorCreateFailed": "创建工作区失败",
"errorInvalidName": "工作区名称不能包含以下字符:/ \\ : * ? \" < > |"
"errorInvalidName": "工作区名称不能包含以下字符:/ \\ : * ? \" < > |",
"errorAlreadyExists": "所选位置已存在同名工作区",
"errorParentNoAccess": "没有所选父目录的访问权限",
"errorNameTooLong": "工作区名称过长(最多 255 个字符)",
"errorPathNotFound": "工作区文件夹不存在或已被移动"
},
"peerDirectoryPicker": {
"loading": "正在加载远程目录…",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2356,7 +2356,9 @@
}
},
"errors": {
"sendFailed": "发送消息失败"
"sendFailed": "发送消息失败",
"thinkingProcessError": "思考过程错误",
"workspaceNotResolvable": "工作区文件夹不存在或已被移动"
},
"actions": {
"send": "发送"
Expand Down
6 changes: 5 additions & 1 deletion src/web-ui/src/locales/zh-TW/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,11 @@
"errorSelectParent": "請選擇父目錄",
"errorEnterName": "請輸入工作區名稱",
"errorCreateFailed": "建立工作區失敗",
"errorInvalidName": "工作區名稱不能包含以下字符:/ \\ : * ? \" < > |"
"errorInvalidName": "工作區名稱不能包含以下字符:/ \\ : * ? \" < > |",
"errorAlreadyExists": "所選位置已存在同名工作區",
"errorParentNoAccess": "沒有所選父目錄的存取權限",
"errorNameTooLong": "工作區名稱過長(最多 255 個字元)",
"errorPathNotFound": "工作區資料夾不存在或已被移動"
},
"peerDirectoryPicker": {
"loading": "正在載入遠端目錄…",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2356,7 +2356,9 @@
}
},
"errors": {
"sendFailed": "傳送訊息失敗"
"sendFailed": "傳送訊息失敗",
"thinkingProcessError": "思考過程錯誤",
"workspaceNotResolvable": "工作區資料夾不存在或已被移動"
},
"actions": {
"send": "傳送"
Expand Down
Loading