Compare commits

..

31 Commits

Author SHA1 Message Date
simosmik
f16e3e763d Release 1.14.0 2026-01-26 00:11:12 +00:00
simosmik
477bc404b0 fix: switch login mechanism for claude code 2026-01-25 23:55:05 +00:00
viper151
ae5a21cd6e Merge pull request #337 from timbot/main
fix: prevent codex spawn error when codex CLI is not installed
2026-01-25 23:18:57 +01:00
viper151
b2c69d6ea8 Merge branch 'main' into main 2026-01-25 23:17:29 +01:00
viper151
8825baf5b4 Update codex.js 2026-01-25 23:17:08 +01:00
viper151
0d1a3df1f7 Merge pull request #318 from siteboon/fix/session-streamed-to-another-chat
Fix/session streamed to another chat
2026-01-25 23:15:31 +01:00
viper151
80732923b5 Merge branch 'main' into fix/session-streamed-to-another-chat 2026-01-25 23:15:23 +01:00
viper151
6362d35d66 Merge pull request #299 from siteboon/feat/add-highlight-to-file-mentions
feat: add highlight for file mentions in chat input
2026-01-25 23:09:24 +01:00
viper151
10bfeed614 Merge branch 'main' into feat/add-highlight-to-file-mentions 2026-01-25 23:09:16 +01:00
Tim Smith
dab089b29f fix: prevent codex spawn error when codex CLI is not installed
Return success with empty servers array from the config read endpoint
when no config file exists, so the frontend doesn't fall through to
the CLI list endpoint which attempts to spawn the codex binary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:21:43 -08:00
Haileyesus Dessie
38745bdf85 Merge pull request #327 from NobitaYuan/feat/add-i18n
update: Add translations for more components
2026-01-23 15:26:51 +03:00
Haileyesus Dessie
9da7c1cbae Merge pull request #314 from EricBlanquer/feature/delete-project-with-sessions
feat: allow deleting projects with sessions and add styled confirmation modal
2026-01-23 15:13:25 +03:00
YuanNiancai
844677caee Merge branch 'feat/add-i18n' of https://github.com/NobitaYuan/claudecodeui into feat/add-i18n 2026-01-23 09:27:14 +08:00
NobitaYuan
e1c67fd5d0 Merge branch 'main' into feat/add-i18n 2026-01-23 09:26:58 +08:00
YuanNiancai
9cd0cfc88f fix: add missing translation 2026-01-23 09:26:43 +08:00
viper151
09f1021c59 Merge pull request #332 from siteboon/fix/turn-on-extended-thinking-mode 2026-01-22 11:45:27 +01:00
viper151
053d94ab9d Merge pull request #331 from siteboon/fix/turn-on-extended-thinking-mode 2026-01-22 11:39:13 +01:00
YuanNiancai
e85cc746b1 fix: add missing translation 2026-01-22 15:24:27 +08:00
YuanNiancai
cc3368c591 add translations for Shell.jsx 2026-01-22 15:12:01 +08:00
YuanNiancai
5131d2ae27 add some translations for CodeEditor.jsx、QuickSettingsPanel.jsx 2026-01-22 14:57:32 +08:00
YuanNiancai
394b95ae29 add some translations for chatInterface.jsx 2026-01-22 14:38:24 +08:00
YuanNiancai
4948aa3d64 fix:Fix missing imports 2026-01-22 10:07:40 +08:00
NobitaYuan
6e07f140e3 Merge branch 'main' into feat/add-i18n 2026-01-22 09:56:11 +08:00
YuanNiancai
fea8e30725 update: Add translations for some components 2026-01-22 09:49:19 +08:00
Eric Blanquer​
9f534ce15b fix: use i18next v4+ pluralization format and add sessionTitle fallback 2026-01-21 23:14:41 +01:00
Eric Blanquer​
8cb34a73b5 fix: localize delete confirmation modal strings 2026-01-21 22:38:29 +01:00
Eric Blanquer​
74640a7f31 feat: allow deleting projects with sessions and add styled confirmation modal
- Add force delete option to delete projects with existing sessions
- Add styled confirmation modal with session count warning
- Add deletingProjects state to show loading indicator during deletion
- Delete associated Codex sessions when deleting a project (with limit: 0)
- Delete associated Cursor sessions directory when deleting a project
- Add fallback to extractProjectDirectory when projectPath undefined
- Use finally block for deletingProjects cleanup
- Add fallback name in delete modal
2026-01-21 22:05:00 +01:00
viper151
9cd1b5811a Merge branch 'main' into fix/session-streamed-to-another-chat 2026-01-20 12:59:42 +01:00
Haileyesus Dessie
ddb26c7652 fix: resolve issue with redirecting to original session after response completion 2026-01-16 14:04:37 +03:00
Haileyesus Dessie
b3c6e95971 fix: don't stream response to another session 2026-01-16 14:04:12 +03:00
Haileyesus Dessie
9da8e69476 feat: add highlight for file mentions in chat input 2026-01-14 17:01:38 +03:00
29 changed files with 818 additions and 178 deletions

View File

@@ -1,6 +1,6 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
<h1>Cloud CLI (又名 Claude Code UI)</h1>
</div>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.6",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.13.6",
"version": "1.14.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.6",
"version": "1.14.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",

View File

@@ -603,7 +603,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
const transformedMessage = transformMessage(message);
ws.send({
type: 'claude-response',
data: transformedMessage
data: transformedMessage,
sessionId: capturedSessionId || sessionId || null
});
// Extract and send token budget updates from result messages
@@ -613,7 +614,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
console.log('Token budget from modelUsage:', tokenBudget);
ws.send({
type: 'token-budget',
data: tokenBudget
data: tokenBudget,
sessionId: capturedSessionId || sessionId || null
});
}
}
@@ -651,7 +653,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send error to WebSocket
ws.send({
type: 'claude-error',
error: error.message
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
throw error;

View File

@@ -114,7 +114,8 @@ async function spawnCursor(command, options = {}, ws) {
// Send system info to frontend
ws.send({
type: 'cursor-system',
data: response
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
break;
@@ -123,7 +124,8 @@ async function spawnCursor(command, options = {}, ws) {
// Forward user message
ws.send({
type: 'cursor-user',
data: response
data: response,
sessionId: capturedSessionId || sessionId || null
});
break;
@@ -142,7 +144,8 @@ async function spawnCursor(command, options = {}, ws) {
type: 'text_delta',
text: textContent
}
}
},
sessionId: capturedSessionId || sessionId || null
});
}
break;
@@ -157,7 +160,8 @@ async function spawnCursor(command, options = {}, ws) {
type: 'claude-response',
data: {
type: 'content_block_stop'
}
},
sessionId: capturedSessionId || sessionId || null
});
}
@@ -174,7 +178,8 @@ async function spawnCursor(command, options = {}, ws) {
// Forward any other message types
ws.send({
type: 'cursor-response',
data: response
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
} catch (parseError) {
@@ -182,7 +187,8 @@ async function spawnCursor(command, options = {}, ws) {
// If not JSON, send as raw text
ws.send({
type: 'cursor-output',
data: line
data: line,
sessionId: capturedSessionId || sessionId || null
});
}
}
@@ -193,7 +199,8 @@ async function spawnCursor(command, options = {}, ws) {
console.error('Cursor CLI stderr:', data.toString());
ws.send({
type: 'cursor-error',
error: data.toString()
error: data.toString(),
sessionId: capturedSessionId || sessionId || null
});
});
@@ -229,7 +236,8 @@ async function spawnCursor(command, options = {}, ws) {
ws.send({
type: 'cursor-error',
error: error.message
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
reject(error);
@@ -264,4 +272,4 @@ export {
abortCursorSession,
isCursorSessionActive,
getActiveCursorSessions
};
};

View File

@@ -455,11 +455,12 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
}
});
// Delete project endpoint (only if empty)
// Delete project endpoint (force=true to delete with sessions)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
await deleteProject(projectName);
const force = req.query.force === 'true';
await deleteProject(projectName, force);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });

View File

@@ -272,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) {
data: {
used: totalTokens,
total: 200000 // Default context window for Codex models
}
},
sessionId: currentSessionId
});
}
}

View File

@@ -1026,25 +1026,56 @@ async function isProjectEmpty(projectName) {
}
}
// Delete an empty project
async function deleteProject(projectName) {
// Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName, force = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) {
if (!isEmpty && !force) {
throw new Error('Cannot delete project with existing sessions');
}
// Remove the project directory
await fs.rm(projectDir, { recursive: true, force: true });
// Remove from project config
const config = await loadProjectConfig();
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
// Fallback to extractProjectDirectory if projectPath is not in config
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Remove the project directory (includes all Claude sessions)
await fs.rm(projectDir, { recursive: true, force: true });
// Delete all Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
}
}
// Remove from project config
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
@@ -1055,17 +1086,17 @@ async function deleteProject(projectName) {
// Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath);
try {
// Check if the path exists
await fs.access(absolutePath);
} catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
// Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-');
// Check if project already exists in config
const config = await loadProjectConfig();
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
@@ -1076,13 +1107,13 @@ async function addProjectManually(projectPath, displayName = null) {
// Allow adding projects even if the directory exists - this enables tracking
// existing Claude Code or Cursor projects in the UI
// Add to config as manually added project
config[projectName] = {
manuallyAdded: true,
originalPath: absolutePath
};
if (displayName) {
config[projectName].displayName = displayName;
}
@@ -1214,7 +1245,8 @@ async function getCursorSessions(projectPath) {
// Fetch Codex sessions for a given project path
async function getCodexSessions(projectPath) {
async function getCodexSessions(projectPath, options = {}) {
const { limit = 5 } = options;
try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const sessions = [];
@@ -1279,8 +1311,8 @@ async function getCodexSessions(projectPath) {
// Sort sessions by last activity (newest first)
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
// Return only the first 5 sessions for performance
return sessions.slice(0, 5);
// Return limited sessions for performance (0 = unlimited for deletion)
return limit > 0 ? sessions.slice(0, limit) : sessions;
} catch (error) {
console.error('Error fetching Codex sessions:', error);

View File

@@ -262,8 +262,7 @@ router.get('/mcp/config/read', async (req, res) => {
}
if (!configData) {
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
}
return res.json({ success: true, configPath, servers: [] }); }
const servers = [];

View File

@@ -36,7 +36,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck';
import useLocalStorage from './hooks/useLocalStorage';
import { api, authenticatedFetch } from './utils/api';
import { I18nextProvider } from 'react-i18next';
import { I18nextProvider, useTranslation } from 'react-i18next';
import i18n from './i18n/config.js';
@@ -44,6 +44,7 @@ import i18n from './i18n/config.js';
function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
const { t } = useTranslation('common');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false);
@@ -579,6 +580,7 @@ function AppContent() {
// Version Upgrade Modal Component
const VersionUpgradeModal = () => {
const { t } = useTranslation('common');
const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState('');
@@ -639,7 +641,7 @@ function AppContent() {
<button
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setShowVersionModal(false)}
aria-label="Close version upgrade modal"
aria-label={t('versionUpdate.ariaLabels.closeModal')}
/>
{/* Modal */}
@@ -653,9 +655,9 @@ function AppContent() {
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('versionUpdate.title')}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{releaseInfo?.title || 'A new version is ready'}
{releaseInfo?.title || t('versionUpdate.newVersionReady')}
</p>
</div>
</div>
@@ -672,11 +674,11 @@ function AppContent() {
{/* Version Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('versionUpdate.currentVersion')}</span>
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
</div>
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('versionUpdate.latestVersion')}</span>
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
</div>
</div>
@@ -685,7 +687,7 @@ function AppContent() {
{releaseInfo?.body && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.whatsNew')}</h3>
{releaseInfo?.htmlUrl && (
<a
href={releaseInfo.htmlUrl}
@@ -693,7 +695,7 @@ function AppContent() {
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
>
View full release
{t('versionUpdate.viewFullRelease')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
@@ -711,7 +713,7 @@ function AppContent() {
{/* Update Output */}
{updateOutput && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.updateProgress')}</h3>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
</div>
@@ -721,14 +723,14 @@ function AppContent() {
{/* Upgrade Instructions */}
{!isUpdating && !updateOutput && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.manualUpgrade')}</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Or click "Update Now" to run the update automatically.
{t('versionUpdate.manualUpgradeHint')}
</p>
</div>
)}
@@ -739,7 +741,7 @@ function AppContent() {
onClick={() => setShowVersionModal(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
{updateOutput ? 'Close' : 'Later'}
{updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
</button>
{!updateOutput && (
<>
@@ -749,7 +751,7 @@ function AppContent() {
}}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Copy Command
{t('versionUpdate.buttons.copyCommand')}
</button>
<button
onClick={handleUpdateNow}
@@ -759,10 +761,10 @@ function AppContent() {
{isUpdating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Updating...
{t('versionUpdate.buttons.updating')}
</>
) : (
'Update Now'
t('versionUpdate.buttons.updateNow')
)}
</button>
</>
@@ -813,8 +815,8 @@ function AppContent() {
<button
onClick={() => setSidebarVisible(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
aria-label="Show sidebar"
title="Show sidebar"
aria-label={t('versionUpdate.ariaLabels.showSidebar')}
title={t('versionUpdate.ariaLabels.showSidebar')}
>
<svg
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
@@ -830,8 +832,8 @@ function AppContent() {
<button
onClick={() => setShowSettings(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Settings"
title="Settings"
aria-label={t('versionUpdate.ariaLabels.settings')}
title={t('versionUpdate.ariaLabels.settings')}
>
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
</button>
@@ -841,8 +843,8 @@ function AppContent() {
<button
onClick={() => setShowVersionModal(true)}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Update available"
title="Update available"
aria-label={t('versionUpdate.ariaLabels.updateAvailable')}
title={t('versionUpdate.ariaLabels.updateAvailable')}
>
<Sparkles className="w-5 h-5 text-blue-500" />
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
@@ -870,7 +872,7 @@ function AppContent() {
e.stopPropagation();
setSidebarOpen(false);
}}
aria-label="Close sidebar"
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/>
<div
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${

View File

@@ -97,6 +97,10 @@ function unescapeWithMathProtection(text) {
return processedText;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Small wrapper to keep markdown behavior consistent in one place
const Markdown = ({ children, className }) => {
const content = normalizeInlineCodeFences(String(children ?? ''));
@@ -1894,6 +1898,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
const inputContainerRef = useRef(null);
const inputHighlightRef = useRef(null);
const scrollContainerRef = useRef(null);
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
const isLoadingMoreRef = useRef(false);
@@ -1902,10 +1907,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Streaming throttle buffers
const streamBufferRef = useRef('');
const streamTimerRef = useRef(null);
// Track the session that this view expects when starting a brandnew chat
// (prevents background sessions from streaming into a different view).
const pendingViewSessionRef = useRef(null);
const commandQueryTimerRef = useRef(null);
const [debouncedInput, setDebouncedInput] = useState('');
const [showFileDropdown, setShowFileDropdown] = useState(false);
const [fileList, setFileList] = useState([]);
const [fileMentions, setFileMentions] = useState([]);
const [filteredFiles, setFilteredFiles] = useState([]);
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
const [cursorPosition, setCursorPosition] = useState(0);
@@ -1939,6 +1948,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Track provider transitions so we only clear approvals when provider truly changes.
// This does not sync with the backend; it just prevents UI prompts from disappearing.
const lastProviderRef = useRef(provider);
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
streamBufferRef.current = '';
}, []);
// Load permission mode for the current session
useEffect(() => {
if (selectedSession?.id) {
@@ -3013,6 +3030,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) {
if (!isSystemSessionChange) {
// Clear any streaming leftovers from the previous session
resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]);
setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
}
// Reset pagination state when switching sessions
setMessagesOffset(0);
setHasMoreMessages(false);
@@ -3082,17 +3108,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
} else {
// Only clear messages if this is NOT a system-initiated session change AND we're not loading
// During system session changes or while loading, preserve the chat messages
if (!isSystemSessionChange && !isLoading) {
// New session view (no selected session) - always reset UI state
if (!isSystemSessionChange) {
resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]);
setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
}
setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId');
setMessagesOffset(0);
setHasMoreMessages(false);
setTotalMessages(0);
setTokenBudget(null);
}
// Mark loading as complete after messages are set
@@ -3103,7 +3134,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
};
loadMessages();
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]);
// External Message Update Handler: Reload messages when external CLI modifies current session
// This triggers when App.jsx detects a JSONL file change for the currently-viewed session
@@ -3142,6 +3173,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]);
// When the user navigates to a specific session, clear any pending "new session" marker.
useEffect(() => {
if (selectedSession?.id) {
pendingViewSessionRef.current = null;
}
}, [selectedSession?.id]);
// Update chatMessages when convertedMessages changes
useEffect(() => {
if (sessionMessages.length > 0) {
@@ -3206,17 +3244,77 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle WebSocket messages
if (messages.length > 0) {
const latestMessage = messages[messages.length - 1];
const messageData = latestMessage.data?.message || latestMessage.data;
// Filter messages by session ID to prevent cross-session interference
// Skip filtering for global messages that apply to all sessions
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete'];
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
const lifecycleMessageTypes = new Set([
'claude-complete',
'codex-complete',
'cursor-result',
'session-aborted',
'claude-error',
'cursor-error',
'codex-error'
]);
// For new sessions (currentSessionId is null), allow messages through
if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) {
// Message is for a different session, ignore it
console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId);
return;
const isClaudeSystemInit = latestMessage.type === 'claude-response' &&
messageData &&
messageData.type === 'system' &&
messageData.subtype === 'init';
const isCursorSystemInit = latestMessage.type === 'cursor-system' &&
latestMessage.data &&
latestMessage.data.type === 'system' &&
latestMessage.data.subtype === 'init';
const systemInitSessionId = isClaudeSystemInit
? messageData?.session_id
: isCursorSystemInit
? latestMessage.data?.session_id
: null;
const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const isSystemInitForView = systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || isSystemInitForView;
const isUnscopedError = !latestMessage.sessionId &&
pendingViewSessionRef.current &&
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error');
const handleBackgroundLifecycle = (sessionId) => {
if (!sessionId) return;
if (onSessionInactive) {
onSessionInactive(sessionId);
}
if (onSessionNotProcessing) {
onSessionNotProcessing(sessionId);
}
};
if (!shouldBypassSessionFilter) {
if (!activeViewSessionId) {
// No session in view; ignore session-scoped traffic.
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
if (!isUnscopedError) {
return;
}
}
if (!latestMessage.sessionId && !isUnscopedError) {
// Drop unscoped messages to prevent cross-session bleed.
return;
}
if (latestMessage.sessionId !== activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
// Message is for a different session, ignore it
console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId);
return;
}
}
switch (latestMessage.type) {
@@ -3225,6 +3323,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Store it temporarily until conversation completes (prevents premature session association)
if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
}
// Mark as system change to prevent clearing messages when session ID updates
setIsSystemSessionChange(true);
@@ -3253,7 +3354,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
break;
case 'claude-response':
const messageData = latestMessage.data.message || latestMessage.data;
// Handle Cursor streaming format (content_block_delta / content_block_stop)
if (messageData && typeof messageData === 'object' && messageData.type) {
@@ -3322,7 +3422,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id &&
currentSessionId &&
latestMessage.data.session_id !== currentSessionId) {
latestMessage.data.session_id !== currentSessionId &&
isSystemInitForView) {
console.log('🔄 Claude CLI session duplication detected:', {
originalSession: currentSessionId,
@@ -3345,7 +3446,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (latestMessage.data.type === 'system' &&
latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id &&
!currentSessionId) {
!currentSessionId &&
isSystemInitForView) {
console.log('🔄 New session init detected:', {
newSession: latestMessage.data.session_id
@@ -3366,7 +3468,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id &&
currentSessionId &&
latestMessage.data.session_id === currentSessionId) {
latestMessage.data.session_id === currentSessionId &&
isSystemInitForView) {
console.log('🔄 System init message for current session, ignoring');
return; // Don't process the message further
}
@@ -3534,6 +3637,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
try {
const cdata = latestMessage.data;
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
if (!isSystemInitForView) {
return;
}
// If we already have a session and this differs, switch (duplication/redirect)
if (currentSessionId && cdata.session_id !== currentSessionId) {
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
@@ -4002,6 +4108,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return result;
};
// Handle @ symbol detection and file filtering
useEffect(() => {
const textBeforeCursor = input.slice(0, cursorPosition);
@@ -4032,6 +4139,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [input, cursorPosition, fileList]);
const activeFileMentions = useMemo(() => {
if (!input || fileMentions.length === 0) return [];
return fileMentions.filter(path => input.includes(path));
}, [fileMentions, input]);
const sortedFileMentions = useMemo(() => {
if (activeFileMentions.length === 0) return [];
const unique = Array.from(new Set(activeFileMentions));
return unique.sort((a, b) => b.length - a.length);
}, [activeFileMentions]);
const fileMentionRegex = useMemo(() => {
if (sortedFileMentions.length === 0) return null;
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
return new RegExp(`(${pattern})`, 'g');
}, [sortedFileMentions]);
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
const renderInputWithMentions = useCallback((text) => {
if (!text) return '';
if (!fileMentionRegex) return text;
const parts = text.split(fileMentionRegex);
return parts.map((part, index) => (
fileMentionSet.has(part) ? (
<span
key={`mention-${index}`}
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
>
{part}
</span>
) : (
<span key={`text-${index}`}>{part}</span>
)
));
}, [fileMentionRegex, fileMentionSet]);
// Debounced input handling
useEffect(() => {
const timer = setTimeout(() => {
@@ -4332,6 +4476,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Session Protection: Mark session as active to prevent automatic project updates during conversation
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
if (!effectiveSessionId && !selectedSession?.id) {
// We are starting a brand-new session in this view. Track it so we only
// accept streaming updates for this run.
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
if (onSessionActive) {
onSessionActive(sessionToActivate);
}
@@ -4614,8 +4763,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const spaceIndex = textAfterAtQuery.indexOf(' ');
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
const newInput = textBeforeAt + file.path + ' ' + textAfterQuery;
const newCursorPos = textBeforeAt.length + file.path.length + 1;
// Immediately ensure focus is maintained
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
@@ -4625,6 +4774,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Update input and cursor position
setInput(newInput);
setCursorPosition(newCursorPos);
setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path]));
// Hide dropdown
setShowFileDropdown(false);
@@ -4718,6 +4868,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
};
const syncInputOverlayScroll = useCallback((target) => {
if (!inputHighlightRef.current || !target) return;
inputHighlightRef.current.scrollTop = target.scrollTop;
inputHighlightRef.current.scrollLeft = target.scrollLeft;
}, []);
const handleTextareaClick = (e) => {
setCursorPosition(e.target.selectionStart);
};
@@ -4789,16 +4945,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
<p>Loading session messages...</p>
<p>{t('session.loading.sessionMessages')}</p>
</div>
</div>
) : chatMessages.length === 0 ? (
<div className="flex items-center justify-center h-full">
{!selectedSession && !currentSessionId && (
<div className="text-center px-6 sm:px-4 py-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">Choose Your AI Assistant</h2>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">{t('providerSelection.title')}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Select a provider to start a new conversation
{t('providerSelection.description')}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
@@ -4819,8 +4975,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="flex flex-col items-center justify-center h-full gap-3">
<ClaudeLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Claude</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by Anthropic</p>
<p className="font-semibold text-gray-900 dark:text-white">Claude Code</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
</div>
</div>
{provider === 'claude' && (
@@ -4852,7 +5008,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<CursorLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
<p className="text-xs text-gray-500 dark:text-gray-400">AI Code Editor</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.cursorEditor')}</p>
</div>
</div>
{provider === 'cursor' && (
@@ -4884,7 +5040,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<CodexLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by OpenAI</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.openai')}</p>
</div>
</div>
{provider === 'codex' && (
@@ -4902,7 +5058,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Model Selection - Always reserve space to prevent jumping */}
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Model
{t('providerSelection.selectModel')}
</label>
{provider === 'claude' ? (
<select
@@ -4952,12 +5108,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<p className="text-sm text-gray-500 dark:text-gray-400">
{provider === 'claude'
? `Ready to use Claude with ${claudeModel}. Start typing your message below.`
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor'
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex'
? `Ready to use Codex with ${codexModel}. Start typing your message below.`
: 'Select a provider above to begin'
? t('providerSelection.readyPrompt.codex', { model: codexModel })
: t('providerSelection.readyPrompt.default')
}
</p>
@@ -4974,9 +5130,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)}
{selectedSession && (
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
<p className="font-bold text-lg sm:text-xl mb-3">Continue your conversation</p>
<p className="font-bold text-lg sm:text-xl mb-3">{t('session.continue.title')}</p>
<p className="text-sm sm:text-base leading-relaxed">
Ask questions about your code, request changes, or get help with development tasks
{t('session.continue.description')}
</p>
{/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */}
@@ -4998,7 +5154,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
<p className="text-sm">Loading older messages...</p>
<p className="text-sm">{t('session.loading.olderMessages')}</p>
</div>
</div>
)}
@@ -5008,8 +5164,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
{totalMessages > 0 && (
<span>
Showing {sessionMessages.length} of {totalMessages} messages
<span className="text-xs">Scroll up to load more</span>
{t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })}
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
</span>
)}
</div>
@@ -5018,12 +5174,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
Showing last {visibleMessageCount} messages ({chatMessages.length} total)
<button
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })}
<button
className="ml-1 text-blue-600 hover:text-blue-700 underline"
onClick={loadEarlierMessages}
>
Load earlier messages
{t('session.messages.loadEarlier')}
</button>
</div>
)}
@@ -5429,6 +5585,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
<input {...getInputProps()} />
<div
ref={inputHighlightRef}
aria-hidden="true"
className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"
>
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-sm sm:text-base leading-[21px] sm:leading-6 whitespace-pre-wrap break-words">
{renderInputWithMentions(input)}
</div>
</div>
<div className="relative z-10">
<textarea
ref={textareaRef}
value={input}
@@ -5436,6 +5602,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
onClick={handleTextareaClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onScroll={(e) => syncInputOverlayScroll(e.target)}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onInput={(e) => {
@@ -5443,6 +5610,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
setCursorPosition(e.target.selectionStart);
syncInputOverlayScroll(e.target);
// Check if textarea is expanded (more than 2 lines worth of height)
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
@@ -5511,6 +5679,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
? t('input.hintText.ctrlEnter')
: t('input.hintText.enter')}
</div>
</div>
</div>
</form>
</div>

View File

@@ -12,8 +12,10 @@ import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
const { t } = useTranslation('codeEditor');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -125,13 +127,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHTML += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
@@ -146,7 +148,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Show/hide diff button (only if there's diff info)
if (file.diffInfo) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? 'Hide diff highlighting' : 'Show diff highlighting'}">
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${showDiff ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
@@ -159,7 +161,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Settings button
toolbarHTML += `
<button class="cm-toolbar-btn cm-settings-btn" title="Editor Settings">
<button class="cm-toolbar-btn cm-settings-btn" title="${t('toolbar.settings')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@@ -169,7 +171,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Expand button (only in sidebar mode)
if (isSidebar && onToggleExpand) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? 'Collapse editor' : 'Expand editor to full width'}">
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${isExpanded ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
@@ -463,7 +465,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
) : (
@@ -471,7 +473,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
</div>
@@ -574,7 +576,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
Showing changes
{t('header.showingChanges')}
</span>
)}
</div>
@@ -586,7 +588,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Download file"
title={t('actions.download')}
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
@@ -605,12 +607,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="hidden sm:inline">Saved!</span>
<span className="hidden sm:inline">{t('actions.saved')}</span>
</>
) : (
<>
<Save className="w-5 h-5 md:w-4 md:h-4" />
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
<span className="hidden sm:inline">{saving ? t('actions.saving') : t('actions.save')}</span>
</>
)}
</button>
@@ -619,7 +621,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
@@ -628,7 +630,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Close"
title={t('actions.close')}
>
<X className="w-6 h-6 md:w-4 md:h-4" />
</button>
@@ -686,12 +688,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>{t('footer.lines')} {content.split('\n').length}</span>
<span>{t('footer.characters')} {content.length}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
{t('footer.shortcuts')}
</div>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import StandaloneShell from './StandaloneShell';
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
*/
function LoginModal({
isOpen,
@@ -18,7 +19,8 @@ function LoginModal({
provider = 'claude',
project,
onComplete,
customCommand
customCommand,
isAuthenticated = false
}) {
if (!isOpen) return null;
@@ -29,13 +31,13 @@ function LoginModal({
switch (provider) {
case 'claude':
return 'claude setup-token --dangerously-skip-permissions';
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
case 'codex':
return isPlatform ? 'codex login --device-auth' : 'codex login';
default:
return 'claude setup-token --dangerously-skip-permissions';
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions';
}
};

View File

@@ -233,8 +233,8 @@ const QuickSettingsPanel = ({
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
>
{isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
@@ -383,10 +383,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Default Mode
{t('quickSettings.whisper.modes.default')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Direct transcription of your speech
{t('quickSettings.whisper.modes.defaultDescription')}
</p>
</div>
</label>
@@ -407,10 +407,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Prompt Enhancement
{t('quickSettings.whisper.modes.prompt')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Transform rough ideas into clear, detailed AI prompts
{t('quickSettings.whisper.modes.promptDescription')}
</p>
</div>
</label>
@@ -431,10 +431,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Vibe Mode
{t('quickSettings.whisper.modes.vibe')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format ideas as clear agent instructions with details
{t('quickSettings.whisper.modes.vibeDescription')}
</p>
</div>
</label>

View File

@@ -1966,6 +1966,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
provider={loginProvider}
project={selectedProject}
onComplete={handleLoginComplete}
isAuthenticated={
loginProvider === 'claude' ? claudeAuthStatus.authenticated :
loginProvider === 'cursor' ? cursorAuthStatus.authenticated :
loginProvider === 'codex' ? codexAuthStatus.authenticated :
false
}
/>
</div>
);

View File

@@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { useTranslation } from 'react-i18next';
const xtermStyles = `
.xterm .xterm-screen {
@@ -25,6 +26,7 @@ if (typeof document !== 'undefined') {
}
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const { t } = useTranslation('chat');
const terminalRef = useRef(null);
const terminal = useRef(null);
const fitAddon = useRef(null);
@@ -373,8 +375,8 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
<p>Choose a project to open an interactive shell in that directory</p>
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
<p>{t('shell.selectProject.description')}</p>
</div>
</div>
);
@@ -400,13 +402,13 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">(Initializing...)</span>
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">(Restarting...)</span>
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
)}
</div>
<div className="flex items-center space-x-3">
@@ -414,12 +416,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title="Disconnect from shell"
title={t('shell.actions.disconnectTitle')}
>
<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" />
</svg>
<span>Disconnect</span>
<span>{t('shell.actions.disconnect')}</span>
</button>
)}
@@ -427,12 +429,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Restart Shell (disconnect first)"
title={t('shell.actions.restartTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Restart</span>
<span>{t('shell.actions.restart')}</span>
</button>
</div>
</div>
@@ -443,7 +445,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div>
<div className="text-white">{t('shell.loading')}</div>
</div>
)}
@@ -453,19 +455,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
title="Connect to shell"
title={t('shell.actions.connectTitle')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Continue in Shell</span>
<span>{t('shell.actions.connect')}</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
selectedSession ?
`Resume session: ${sessionDisplayNameLong}...` :
'Start a new Claude session'
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
t('shell.startSession')
}
</p>
</div>
@@ -477,12 +479,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">Connecting to shell...</span>
<span className="text-base font-medium">{t('shell.connecting')}</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
`Starting Claude CLI in ${selectedProject.displayName}`
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
t('shell.startCli', { projectName: selectedProject.displayName })
}
</p>
</div>

View File

@@ -6,7 +6,7 @@ import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { useTranslation } from 'react-i18next';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search, AlertTriangle } from 'lucide-react';
import { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx';
@@ -80,6 +80,9 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({});
const [searchFilter, setSearchFilter] = useState('');
const [deletingProjects, setDeletingProjects] = useState(new Set());
const [deleteConfirmation, setDeleteConfirmation] = useState(null); // { project, sessionCount }
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); // { projectName, sessionId, sessionTitle, provider }
// TaskMaster context
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
@@ -306,10 +309,15 @@ function Sidebar({
setEditingName('');
};
const deleteSession = async (projectName, sessionId, provider = 'claude') => {
if (!confirm(t('messages.deleteSessionConfirm'))) {
return;
}
const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
};
const confirmDeleteSession = async () => {
if (!sessionDeleteConfirmation) return;
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null);
try {
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
@@ -343,18 +351,26 @@ function Sidebar({
}
};
const deleteProject = async (projectName) => {
if (!confirm(t('messages.deleteProjectConfirm'))) {
return;
}
const deleteProject = (project) => {
const sessionCount = getAllSessions(project).length;
setDeleteConfirmation({ project, sessionCount });
};
const confirmDeleteProject = async () => {
if (!deleteConfirmation) return;
const { project, sessionCount } = deleteConfirmation;
const isEmpty = sessionCount === 0;
setDeleteConfirmation(null);
setDeletingProjects(prev => new Set([...prev, project.name]));
try {
const response = await api.deleteProject(projectName);
const response = await api.deleteProject(project.name, !isEmpty);
if (response.ok) {
// Call parent callback if provided
if (onProjectDelete) {
onProjectDelete(projectName);
onProjectDelete(project.name);
}
} else {
const error = await response.json();
@@ -364,6 +380,12 @@ function Sidebar({
} catch (error) {
console.error('Error deleting project:', error);
alert(t('messages.deleteProjectError'));
} finally {
setDeletingProjects(prev => {
const next = new Set(prev);
next.delete(project.name);
return next;
});
}
};
@@ -488,6 +510,110 @@ function Sidebar({
document.body
)}
{/* Delete Confirmation Modal */}
{deleteConfirmation && ReactDOM.createPortal(
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('deleteConfirmation.deleteProject')}
</h3>
<p className="text-sm text-muted-foreground mb-1">
{t('deleteConfirmation.confirmDelete')}{' '}
<span className="font-medium text-foreground">
{deleteConfirmation.project.displayName || deleteConfirmation.project.name}
</span>?
</p>
{deleteConfirmation.sessionCount > 0 && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-300 font-medium">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{t('deleteConfirmation.allConversationsDeleted')}
</p>
</div>
)}
<p className="text-xs text-muted-foreground mt-3">
{t('deleteConfirmation.cannotUndo')}
</p>
</div>
</div>
</div>
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
<Button
variant="outline"
className="flex-1"
onClick={() => setDeleteConfirmation(null)}
>
{t('actions.cancel')}
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
onClick={confirmDeleteProject}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('actions.delete')}
</Button>
</div>
</div>
</div>,
document.body
)}
{/* Session Delete Confirmation Modal */}
{sessionDeleteConfirmation && ReactDOM.createPortal(
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('deleteConfirmation.deleteSession')}
</h3>
<p className="text-sm text-muted-foreground mb-1">
{t('deleteConfirmation.confirmDelete')}{' '}
<span className="font-medium text-foreground">
{sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
</span>?
</p>
<p className="text-xs text-muted-foreground mt-3">
{t('deleteConfirmation.cannotUndo')}
</p>
</div>
</div>
</div>
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
<Button
variant="outline"
className="flex-1"
onClick={() => setSessionDeleteConfirmation(null)}
>
{t('actions.cancel')}
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
onClick={confirmDeleteSession}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('actions.delete')}
</Button>
</div>
</div>
</div>,
document.body
)}
<div
className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
@@ -669,7 +795,7 @@ function Sidebar({
<p className="text-sm text-muted-foreground">
{t('projects.fetchingProjects')}
</p>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.loadingProjects')}</h3>
{loadingProgress && loadingProgress.total > 0 ? (
<div className="space-y-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
@@ -679,7 +805,7 @@ function Sidebar({
/>
</div>
<p className="text-sm text-muted-foreground">
{loadingProgress.current}/{loadingProgress.total} projects
{loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}
</p>
{loadingProgress.currentProject && (
<p className="text-xs text-muted-foreground/70 truncate max-w-[200px] mx-auto" title={loadingProgress.currentProject}>
@@ -689,7 +815,7 @@ function Sidebar({
</div>
) : (
<p className="text-sm text-muted-foreground">
Fetching your Claude projects and sessions
{t('projects.fetchingProjects')}
</p>
)}
</div>
@@ -718,9 +844,10 @@ function Sidebar({
const isExpanded = expandedProjects.has(project.name);
const isSelected = selectedProject?.name === project.name;
const isStarred = isProjectStarred(project.name);
const isDeleting = deletingProjects.has(project.name);
return (
<div key={project.name} className="md:space-y-1">
<div key={project.name} className={cn("md:space-y-1", isDeleting && "opacity-50 pointer-events-none")}>
{/* Project Header */}
<div className="group md:group">
{/* Mobile Project Item */}
@@ -849,18 +976,16 @@ function Sidebar({
: "text-gray-600 dark:text-gray-400"
)} />
</button>
{getAllSessions(project).length === 0 && (
<button
<button
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
deleteProject(project);
}}
onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
onTouchEnd={handleTouchClick(() => deleteProject(project))}
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
)}
<button
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 border border-primary/20 dark:border-primary/30"
onClick={(e) => {
@@ -1009,18 +1134,16 @@ function Sidebar({
>
<Edit3 className="w-3 h-3" />
</div>
{getAllSessions(project).length === 0 && (
<div
<div
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
deleteProject(project);
}}
title={t('tooltips.deleteProject')}
>
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</div>
)}
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
@@ -1152,9 +1275,9 @@ function Sidebar({
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id, session.__provider);
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id, session.__provider))}
onTouchEnd={handleTouchClick(() => showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
@@ -1271,7 +1394,7 @@ function Sidebar({
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id, session.__provider);
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
}}
title={t('tooltips.deleteSession')}
>

View File

@@ -19,12 +19,14 @@ import enSettings from './locales/en/settings.json';
import enAuth from './locales/en/auth.json';
import enSidebar from './locales/en/sidebar.json';
import enChat from './locales/en/chat.json';
import enCodeEditor from './locales/en/codeEditor.json';
import zhCommon from './locales/zh-CN/common.json';
import zhSettings from './locales/zh-CN/settings.json';
import zhAuth from './locales/zh-CN/auth.json';
import zhSidebar from './locales/zh-CN/sidebar.json';
import zhChat from './locales/zh-CN/chat.json';
import zhCodeEditor from './locales/zh-CN/codeEditor.json';
// Import supported languages configuration
import { languages } from './languages.js';
@@ -56,6 +58,7 @@ i18n
auth: enAuth,
sidebar: enSidebar,
chat: enChat,
codeEditor: enCodeEditor,
},
'zh-CN': {
common: zhCommon,
@@ -63,6 +66,7 @@ i18n
auth: zhAuth,
sidebar: zhSidebar,
chat: zhChat,
codeEditor: zhCodeEditor,
},
},
@@ -76,7 +80,7 @@ i18n
debug: import.meta.env.DEV,
// Namespaces - load only what's needed
ns: ['common', 'settings', 'auth', 'sidebar', 'chat'],
ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor'],
defaultNS: 'common',
// Key separator for nested keys (default: '.')

View File

@@ -143,5 +143,63 @@
}
},
"buttonTitle": "Thinking mode: {{mode}}"
},
"providerSelection": {
"title": "Choose Your AI Assistant",
"description": "Select a provider to start a new conversation",
"selectModel": "Select Model",
"providerInfo": {
"anthropic": "by Anthropic",
"openai": "by OpenAI",
"cursorEditor": "AI Code Editor"
},
"readyPrompt": {
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin"
}
},
"session": {
"continue": {
"title": "Continue your conversation",
"description": "Ask questions about your code, request changes, or get help with development tasks"
},
"loading": {
"olderMessages": "Loading older messages...",
"sessionMessages": "Loading session messages..."
},
"messages": {
"showingOf": "Showing {{shown}} of {{total}} messages",
"scrollToLoad": "Scroll up to load more",
"showingLast": "Showing last {{count}} messages ({{total}} total)",
"loadEarlier": "Load earlier messages"
}
},
"shell": {
"selectProject": {
"title": "Select a Project",
"description": "Choose a project to open an interactive shell in that directory"
},
"status": {
"newSession": "New Session",
"initializing": "Initializing...",
"restarting": "Restarting..."
},
"actions": {
"disconnect": "Disconnect",
"disconnectTitle": "Disconnect from shell",
"restart": "Restart",
"restartTitle": "Restart Shell (disconnect first)",
"connect": "Continue in Shell",
"connectTitle": "Connect to shell"
},
"loading": "Loading terminal...",
"connecting": "Connecting to shell...",
"startSession": "Start a new Claude session",
"resumeSession": "Resume session: {{displayName}}...",
"runCommand": "Run {{command}} in {{projectName}}",
"startCli": "Starting Claude CLI in {{projectName}}",
"defaultCommand": "command"
}
}

View File

@@ -0,0 +1,30 @@
{
"toolbar": {
"changes": "changes",
"previousChange": "Previous change",
"nextChange": "Next change",
"hideDiff": "Hide diff highlighting",
"showDiff": "Show diff highlighting",
"settings": "Editor Settings",
"collapse": "Collapse editor",
"expand": "Expand editor to full width"
},
"loading": "Loading {{fileName}}...",
"header": {
"showingChanges": "Showing changes"
},
"actions": {
"download": "Download file",
"save": "Save",
"saving": "Saving...",
"saved": "Saved!",
"exitFullscreen": "Exit fullscreen",
"fullscreen": "Fullscreen",
"close": "Close"
},
"footer": {
"lines": "Lines:",
"characters": "Characters:",
"shortcuts": "Press Ctrl+S to save • Esc to close"
}
}

View File

@@ -186,5 +186,33 @@
"providePath": "Please provide a workspace path",
"failedToCreate": "Failed to create workspace"
}
},
"versionUpdate": {
"title": "Update Available",
"newVersionReady": "A new version is ready",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"whatsNew": "What's New:",
"viewFullRelease": "View full release",
"updateProgress": "Update Progress:",
"manualUpgrade": "Manual upgrade:",
"manualUpgradeHint": "Or click \"Update Now\" to run the update automatically.",
"updateCompleted": "Update completed successfully!",
"restartServer": "Please restart the server to apply changes.",
"updateFailed": "Update failed",
"buttons": {
"close": "Close",
"later": "Later",
"copyCommand": "Copy Command",
"updateNow": "Update Now",
"updating": "Updating..."
},
"ariaLabels": {
"closeModal": "Close version upgrade modal",
"showSidebar": "Show sidebar",
"settings": "Settings",
"updateAvailable": "Update available",
"closeSidebar": "Close sidebar"
}
}
}

View File

@@ -64,7 +64,24 @@
"showThinking": "Show thinking",
"autoScrollToBottom": "Auto-scroll to bottom",
"sendByCtrlEnter": "Send by Ctrl+Enter",
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends."
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
"dragHandle": {
"dragging": "Dragging handle",
"closePanel": "Close settings panel",
"openPanel": "Open settings panel",
"draggingStatus": "Dragging...",
"toggleAndMove": "Click to toggle, drag to move"
},
"whisper": {
"modes": {
"default": "Default Mode",
"defaultDescription": "Direct transcription of your speech",
"prompt": "Prompt Enhancement",
"promptDescription": "Transform rough ideas into clear, detailed AI prompts",
"vibe": "Vibe Mode",
"vibeDescription": "Format ideas as clear agent instructions with details"
}
}
},
"mainTabs": {
"agents": "Agents",

View File

@@ -14,6 +14,7 @@
"newSession": "New Session",
"codexSession": "Codex Session",
"fetchingProjects": "Fetching your Claude projects and sessions",
"projects": "projects",
"noMatchingProjects": "No matching projects",
"tryDifferentSearch": "Try adjusting your search term",
"runClaudeCli": "Run Claude CLI in a project directory to get started"
@@ -98,5 +99,14 @@
},
"version": {
"updateAvailable": "Update available"
},
"deleteConfirmation": {
"deleteProject": "Delete Project",
"deleteSession": "Delete Session",
"confirmDelete": "Are you sure you want to delete",
"sessionCount_one": "This project contains {{count}} conversation.",
"sessionCount_other": "This project contains {{count}} conversations.",
"allConversationsDeleted": "All conversations will be permanently deleted.",
"cannotUndo": "This action cannot be undone."
}
}

View File

@@ -143,5 +143,63 @@
}
},
"buttonTitle": "思考模式:{{mode}}"
},
"providerSelection": {
"title": "选择您的 AI 助手",
"description": "选择一个供应商以开始新对话",
"selectModel": "选择模型",
"providerInfo": {
"anthropic": "Anthropic",
"openai": "OpenAI",
"cursorEditor": "AI 代码编辑器"
},
"readyPrompt": {
"claude": "已准备好使用 Claude {{model}}。在下方输入您的消息。",
"cursor": "已准备好使用 Cursor {{model}}。在下方输入您的消息。",
"codex": "已准备好使用 Codex {{model}}。在下方输入您的消息。",
"default": "请在上方选择一个供应商以开始"
}
},
"session": {
"continue": {
"title": "继续您的对话",
"description": "询问有关代码的问题、请求更改或获取开发任务的帮助"
},
"loading": {
"olderMessages": "正在加载更早的消息...",
"sessionMessages": "正在加载会话消息..."
},
"messages": {
"showingOf": "显示 {{shown}} / {{total}} 条消息",
"scrollToLoad": "向上滚动以加载更多",
"showingLast": "显示最近 {{count}} 条消息(共 {{total}} 条)",
"loadEarlier": "加载更早的消息"
}
},
"shell": {
"selectProject": {
"title": "选择项目",
"description": "选择一个项目以在该目录中打开交互式 Shell"
},
"status": {
"newSession": "新会话",
"initializing": "初始化中...",
"restarting": "重启中..."
},
"actions": {
"disconnect": "断开连接",
"disconnectTitle": "断开 Shell 连接",
"restart": "重启",
"restartTitle": "重启 Shell请先断开连接",
"connect": "在 Shell 中继续",
"connectTitle": "连接到 Shell"
},
"loading": "正在加载终端...",
"connecting": "正在连接到 Shell...",
"startSession": "启动新的 Claude 会话",
"resumeSession": "恢复会话:{{displayName}}...",
"runCommand": "在 {{projectName}} 中运行 {{command}}",
"startCli": "在 {{projectName}} 中启动 Claude CLI",
"defaultCommand": "命令"
}
}

View File

@@ -0,0 +1,30 @@
{
"toolbar": {
"changes": "个更改",
"previousChange": "上一个更改",
"nextChange": "下一个更改",
"hideDiff": "隐藏差异高亮",
"showDiff": "显示差异高亮",
"settings": "编辑器设置",
"collapse": "折叠编辑器",
"expand": "展开编辑器到全宽"
},
"loading": "正在加载 {{fileName}}...",
"header": {
"showingChanges": "显示更改"
},
"actions": {
"download": "下载文件",
"save": "保存",
"saving": "保存中...",
"saved": "已保存!",
"exitFullscreen": "退出全屏",
"fullscreen": "全屏",
"close": "关闭"
},
"footer": {
"lines": "行数:",
"characters": "字符数:",
"shortcuts": "按 Ctrl+S 保存 • Esc 关闭"
}
}

View File

@@ -186,5 +186,33 @@
"providePath": "请提供工作区路径",
"failedToCreate": "创建工作区失败"
}
},
"versionUpdate": {
"title": "有可用更新",
"newVersionReady": "新版本已准备就绪",
"currentVersion": "当前版本",
"latestVersion": "最新版本",
"whatsNew": "新内容:",
"viewFullRelease": "查看完整发布",
"updateProgress": "更新进度:",
"manualUpgrade": "手动升级:",
"manualUpgradeHint": "或点击'立即更新'以自动运行更新。",
"updateCompleted": "更新成功完成!",
"restartServer": "请重启服务器以应用更改。",
"updateFailed": "更新失败",
"buttons": {
"close": "关闭",
"later": "稍后",
"copyCommand": "复制命令",
"updateNow": "立即更新",
"updating": "更新中..."
},
"ariaLabels": {
"closeModal": "关闭版本升级模态框",
"showSidebar": "显示侧边栏",
"settings": "设置",
"updateAvailable": "有可用更新",
"closeSidebar": "关闭侧边栏"
}
}
}

View File

@@ -64,7 +64,24 @@
"showThinking": "显示思考过程",
"autoScrollToBottom": "自动滚动到底部",
"sendByCtrlEnter": "使用 Ctrl+Enter 发送",
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。"
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。",
"dragHandle": {
"dragging": "正在拖拽手柄",
"closePanel": "关闭设置面板",
"openPanel": "打开设置面板",
"draggingStatus": "正在拖拽...",
"toggleAndMove": "点击切换,拖拽移动"
},
"whisper": {
"modes": {
"default": "默认模式",
"defaultDescription": "直接转录您的语音",
"prompt": "提示词增强",
"promptDescription": "将粗略的想法转化为清晰、详细的 AI 提示词",
"vibe": "Vibe 模式",
"vibeDescription": "将想法格式化为带有详细说明的清晰智能体指令"
}
}
},
"mainTabs": {
"agents": "智能体",

View File

@@ -14,6 +14,7 @@
"newSession": "新会话",
"codexSession": "Codex 会话",
"fetchingProjects": "正在获取您的 Claude 项目和会话",
"projects": "项目",
"noMatchingProjects": "未找到匹配的项目",
"tryDifferentSearch": "尝试调整您的搜索词",
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
@@ -98,5 +99,14 @@
},
"version": {
"updateAvailable": "有可用更新"
},
"deleteConfirmation": {
"deleteProject": "删除项目",
"deleteSession": "删除会话",
"confirmDelete": "您确定要删除",
"sessionCount_one": "此项目包含 {{count}} 个对话。",
"sessionCount_other": "此项目包含 {{count}} 个对话。",
"allConversationsDeleted": "所有对话将被永久删除。",
"cannotUndo": "此操作无法撤销。"
}
}

View File

@@ -79,8 +79,8 @@ export const api = {
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
method: 'DELETE',
}),
deleteProject: (projectName) =>
authenticatedFetch(`/api/projects/${projectName}`, {
deleteProject: (projectName, force = false) =>
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
method: 'DELETE',
}),
createProject: (path) =>