-
-
-
- Something went wrong
-
-
-
-
An error occurred while loading the chat interface.
- {this.props.showDetails && this.state.error && (
-
- Error Details
-
- {this.state.error.toString()}
- {this.state.errorInfo && this.state.errorInfo.componentStack}
-
-
- )}
-
-
- {
- this.setState({ hasError: false, error: null, errorInfo: null });
- if (this.props.onRetry) this.props.onRetry();
- }}
- className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
- >
- Try Again
-
-
+function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
+ return (
+
+
+
+
+
+ Something went wrong
+
- );
- }
-
- return this.props.children;
- }
+
+
An error occurred while loading the chat interface.
+ {showDetails && error && (
+
+ Error Details
+
+ {error.toString()}
+ {componentStack}
+
+
+ )}
+
+
+
+ Try Again
+
+
+
+
+ );
}
-export default ErrorBoundary;
\ No newline at end of file
+function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
+ const [componentStack, setComponentStack] = useState(null);
+
+ const handleError = useCallback((error, errorInfo) => {
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
+ setComponentStack(errorInfo?.componentStack || null);
+ }, []);
+
+ const handleReset = useCallback(() => {
+ setComponentStack(null);
+ onRetry?.();
+ }, [onRetry]);
+
+ const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
+
+ ), [showDetails, componentStack]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default ErrorBoundary;
diff --git a/src/components/FileTree.jsx b/src/components/FileTree.jsx
deleted file mode 100644
index cf3d1e1..0000000
--- a/src/components/FileTree.jsx
+++ /dev/null
@@ -1,729 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { useTranslation } from 'react-i18next';
-import { ScrollArea } from './ui/scroll-area';
-import { Button } from './ui/button';
-import { Input } from './ui/input';
-import {
- Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X,
- ChevronRight,
- FileJson, FileType, FileSpreadsheet, FileArchive,
- Hash, Braces, Terminal, Database, Globe, Palette, Music2, Video, Archive,
- Lock, Shield, Settings, Image, BookOpen, Cpu, Box, Gem, Coffee,
- Flame, Hexagon, FileCode2, Code2, Cog, FileWarning, Binary, SquareFunction,
- Scroll, FlaskConical, NotebookPen, FileCheck, Workflow, Blocks
-} from 'lucide-react';
-import { cn } from '../lib/utils';
-import ImageViewer from './ImageViewer';
-import { api } from '../utils/api';
-
-// ─── File Icon Registry ──────────────────────────────────────────────
-// Maps file extensions (and special filenames) to { icon, colorClass } pairs.
-// Uses lucide-react icons mapped semantically to file types.
-
-const ICON_SIZE = 'w-4 h-4 flex-shrink-0';
-
-const FILE_ICON_MAP = {
- // ── JavaScript / TypeScript ──
- js: { icon: FileCode, color: 'text-yellow-500' },
- jsx: { icon: FileCode, color: 'text-yellow-500' },
- mjs: { icon: FileCode, color: 'text-yellow-500' },
- cjs: { icon: FileCode, color: 'text-yellow-500' },
- ts: { icon: FileCode2, color: 'text-blue-500' },
- tsx: { icon: FileCode2, color: 'text-blue-500' },
- mts: { icon: FileCode2, color: 'text-blue-500' },
-
- // ── Python ──
- py: { icon: Code2, color: 'text-emerald-500' },
- pyw: { icon: Code2, color: 'text-emerald-500' },
- pyi: { icon: Code2, color: 'text-emerald-400' },
- ipynb:{ icon: NotebookPen, color: 'text-orange-500' },
-
- // ── Rust ──
- rs: { icon: Cog, color: 'text-orange-600' },
- toml: { icon: Settings, color: 'text-gray-500' },
-
- // ── Go ──
- go: { icon: Hexagon, color: 'text-cyan-500' },
-
- // ── Ruby ──
- rb: { icon: Gem, color: 'text-red-500' },
- erb: { icon: Gem, color: 'text-red-400' },
-
- // ── PHP ──
- php: { icon: Blocks, color: 'text-violet-500' },
-
- // ── Java / Kotlin ──
- java: { icon: Coffee, color: 'text-red-600' },
- jar: { icon: Coffee, color: 'text-red-500' },
- kt: { icon: Hexagon, color: 'text-violet-500' },
- kts: { icon: Hexagon, color: 'text-violet-400' },
-
- // ── C / C++ ──
- c: { icon: Cpu, color: 'text-blue-600' },
- h: { icon: Cpu, color: 'text-blue-400' },
- cpp: { icon: Cpu, color: 'text-blue-700' },
- hpp: { icon: Cpu, color: 'text-blue-500' },
- cc: { icon: Cpu, color: 'text-blue-700' },
-
- // ── C# ──
- cs: { icon: Hexagon, color: 'text-purple-600' },
-
- // ── Swift ──
- swift:{ icon: Flame, color: 'text-orange-500' },
-
- // ── Lua ──
- lua: { icon: SquareFunction, color: 'text-blue-500' },
-
- // ── R ──
- r: { icon: FlaskConical, color: 'text-blue-600' },
-
- // ── Web ──
- html: { icon: Globe, color: 'text-orange-600' },
- htm: { icon: Globe, color: 'text-orange-600' },
- css: { icon: Hash, color: 'text-blue-500' },
- scss: { icon: Hash, color: 'text-pink-500' },
- sass: { icon: Hash, color: 'text-pink-400' },
- less: { icon: Hash, color: 'text-indigo-500' },
- vue: { icon: FileCode2, color: 'text-emerald-500' },
- svelte:{ icon: FileCode2, color: 'text-orange-500' },
-
- // ── Data / Config ──
- json: { icon: Braces, color: 'text-yellow-600' },
- jsonc:{ icon: Braces, color: 'text-yellow-500' },
- json5:{ icon: Braces, color: 'text-yellow-500' },
- yaml: { icon: Settings, color: 'text-purple-400' },
- yml: { icon: Settings, color: 'text-purple-400' },
- xml: { icon: FileCode, color: 'text-orange-500' },
- csv: { icon: FileSpreadsheet, color: 'text-green-600' },
- tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
- sql: { icon: Database, color: 'text-blue-500' },
- graphql:{ icon: Workflow, color: 'text-pink-500' },
- gql: { icon: Workflow, color: 'text-pink-500' },
- proto:{ icon: Box, color: 'text-green-500' },
- env: { icon: Shield, color: 'text-yellow-600' },
-
- // ── Documents ──
- md: { icon: BookOpen, color: 'text-blue-500' },
- mdx: { icon: BookOpen, color: 'text-blue-400' },
- txt: { icon: FileText, color: 'text-gray-500' },
- doc: { icon: FileText, color: 'text-blue-600' },
- docx: { icon: FileText, color: 'text-blue-600' },
- pdf: { icon: FileCheck, color: 'text-red-600' },
- rtf: { icon: FileText, color: 'text-gray-500' },
- tex: { icon: Scroll, color: 'text-teal-600' },
- rst: { icon: FileText, color: 'text-gray-400' },
-
- // ── Shell / Scripts ──
- sh: { icon: Terminal, color: 'text-green-500' },
- bash: { icon: Terminal, color: 'text-green-500' },
- zsh: { icon: Terminal, color: 'text-green-400' },
- fish: { icon: Terminal, color: 'text-green-400' },
- ps1: { icon: Terminal, color: 'text-blue-400' },
- bat: { icon: Terminal, color: 'text-gray-500' },
- cmd: { icon: Terminal, color: 'text-gray-500' },
-
- // ── Images ──
- png: { icon: Image, color: 'text-purple-500' },
- jpg: { icon: Image, color: 'text-purple-500' },
- jpeg: { icon: Image, color: 'text-purple-500' },
- gif: { icon: Image, color: 'text-purple-400' },
- webp: { icon: Image, color: 'text-purple-400' },
- ico: { icon: Image, color: 'text-purple-400' },
- bmp: { icon: Image, color: 'text-purple-400' },
- tiff: { icon: Image, color: 'text-purple-400' },
- svg: { icon: Palette, color: 'text-amber-500' },
-
- // ── Audio ──
- mp3: { icon: Music2, color: 'text-pink-500' },
- wav: { icon: Music2, color: 'text-pink-500' },
- ogg: { icon: Music2, color: 'text-pink-400' },
- flac: { icon: Music2, color: 'text-pink-400' },
- aac: { icon: Music2, color: 'text-pink-400' },
- m4a: { icon: Music2, color: 'text-pink-400' },
-
- // ── Video ──
- mp4: { icon: Video, color: 'text-rose-500' },
- mov: { icon: Video, color: 'text-rose-500' },
- avi: { icon: Video, color: 'text-rose-500' },
- webm: { icon: Video, color: 'text-rose-400' },
- mkv: { icon: Video, color: 'text-rose-400' },
-
- // ── Fonts ──
- ttf: { icon: FileType, color: 'text-red-500' },
- otf: { icon: FileType, color: 'text-red-500' },
- woff: { icon: FileType, color: 'text-red-400' },
- woff2:{ icon: FileType, color: 'text-red-400' },
- eot: { icon: FileType, color: 'text-red-400' },
-
- // ── Archives ──
- zip: { icon: Archive, color: 'text-amber-600' },
- tar: { icon: Archive, color: 'text-amber-600' },
- gz: { icon: Archive, color: 'text-amber-600' },
- bz2: { icon: Archive, color: 'text-amber-600' },
- rar: { icon: Archive, color: 'text-amber-500' },
- '7z': { icon: Archive, color: 'text-amber-500' },
-
- // ── Lock files ──
- lock: { icon: Lock, color: 'text-gray-500' },
-
- // ── Binary / Executable ──
- exe: { icon: Binary, color: 'text-gray-500' },
- bin: { icon: Binary, color: 'text-gray-500' },
- dll: { icon: Binary, color: 'text-gray-400' },
- so: { icon: Binary, color: 'text-gray-400' },
- dylib:{ icon: Binary, color: 'text-gray-400' },
- wasm: { icon: Binary, color: 'text-purple-500' },
-
- // ── Misc config ──
- ini: { icon: Settings, color: 'text-gray-500' },
- cfg: { icon: Settings, color: 'text-gray-500' },
- conf: { icon: Settings, color: 'text-gray-500' },
- log: { icon: Scroll, color: 'text-gray-400' },
- map: { icon: File, color: 'text-gray-400' },
-};
-
-// Special full-filename matches (highest priority)
-const FILENAME_ICON_MAP = {
- 'Dockerfile': { icon: Box, color: 'text-blue-500' },
- 'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
- 'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
- '.dockerignore': { icon: Box, color: 'text-gray-500' },
- '.gitignore': { icon: Settings, color: 'text-gray-500' },
- '.gitmodules': { icon: Settings, color: 'text-gray-500' },
- '.gitattributes': { icon: Settings, color: 'text-gray-500' },
- '.editorconfig': { icon: Settings, color: 'text-gray-500' },
- '.prettierrc': { icon: Settings, color: 'text-pink-400' },
- '.prettierignore': { icon: Settings, color: 'text-gray-500' },
- '.eslintrc': { icon: Settings, color: 'text-violet-500' },
- '.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
- '.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
- '.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
- 'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
- 'eslint.config.mjs':{ icon: Settings, color: 'text-violet-500' },
- '.env': { icon: Shield, color: 'text-yellow-600' },
- '.env.local': { icon: Shield, color: 'text-yellow-600' },
- '.env.development': { icon: Shield, color: 'text-yellow-500' },
- '.env.production': { icon: Shield, color: 'text-yellow-600' },
- '.env.example': { icon: Shield, color: 'text-yellow-400' },
- 'package.json': { icon: Braces, color: 'text-green-500' },
- 'package-lock.json':{ icon: Lock, color: 'text-gray-500' },
- 'yarn.lock': { icon: Lock, color: 'text-blue-400' },
- 'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
- 'bun.lockb': { icon: Lock, color: 'text-gray-400' },
- 'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
- 'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
- 'Gemfile': { icon: Gem, color: 'text-red-500' },
- 'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
- 'Makefile': { icon: Terminal, color: 'text-gray-500' },
- 'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
- 'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
- 'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
- 'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
- 'vite.config.js': { icon: Flame, color: 'text-purple-500' },
- 'webpack.config.js':{ icon: Cog, color: 'text-blue-500' },
- 'tailwind.config.js':{ icon: Hash, color: 'text-cyan-500' },
- 'tailwind.config.ts':{ icon: Hash, color: 'text-cyan-500' },
- 'postcss.config.js':{ icon: Cog, color: 'text-red-400' },
- 'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
- '.babelrc': { icon: Settings, color: 'text-yellow-500' },
- 'README.md': { icon: BookOpen, color: 'text-blue-500' },
- 'LICENSE': { icon: FileCheck, color: 'text-gray-500' },
- 'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
- 'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
- 'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
- 'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
- 'go.sum': { icon: Lock, color: 'text-cyan-400' },
-};
-
-function getFileIconData(filename) {
- // 1. Exact filename match
- if (FILENAME_ICON_MAP[filename]) {
- return FILENAME_ICON_MAP[filename];
- }
-
- // 2. Check for .env prefix pattern
- if (filename.startsWith('.env')) {
- return { icon: Shield, color: 'text-yellow-600' };
- }
-
- // 3. Extension-based lookup
- const ext = filename.split('.').pop()?.toLowerCase();
- if (ext && FILE_ICON_MAP[ext]) {
- return FILE_ICON_MAP[ext];
- }
-
- // 4. Fallback
- return { icon: File, color: 'text-muted-foreground' };
-}
-
-
-// ─── Component ───────────────────────────────────────────────────────
-
-function FileTree({ selectedProject, onFileOpen }) {
- const { t } = useTranslation();
- const [files, setFiles] = useState([]);
- const [loading, setLoading] = useState(false);
- const [expandedDirs, setExpandedDirs] = useState(new Set());
- const [selectedImage, setSelectedImage] = useState(null);
- const [viewMode, setViewMode] = useState('detailed');
- const [searchQuery, setSearchQuery] = useState('');
- const [filteredFiles, setFilteredFiles] = useState([]);
-
- useEffect(() => {
- if (selectedProject) {
- fetchFiles();
- }
- }, [selectedProject]);
-
- useEffect(() => {
- const savedViewMode = localStorage.getItem('file-tree-view-mode');
- if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
- setViewMode(savedViewMode);
- }
- }, []);
-
- useEffect(() => {
- if (!searchQuery.trim()) {
- setFilteredFiles(files);
- } else {
- const filtered = filterFiles(files, searchQuery.toLowerCase());
- setFilteredFiles(filtered);
-
- const expandMatches = (items) => {
- items.forEach(item => {
- if (item.type === 'directory' && item.children && item.children.length > 0) {
- setExpandedDirs(prev => new Set(prev.add(item.path)));
- expandMatches(item.children);
- }
- });
- };
- expandMatches(filtered);
- }
- }, [files, searchQuery]);
-
- const filterFiles = (items, query) => {
- return items.reduce((filtered, item) => {
- const matchesName = item.name.toLowerCase().includes(query);
- let filteredChildren = [];
-
- if (item.type === 'directory' && item.children) {
- filteredChildren = filterFiles(item.children, query);
- }
-
- if (matchesName || filteredChildren.length > 0) {
- filtered.push({
- ...item,
- children: filteredChildren
- });
- }
-
- return filtered;
- }, []);
- };
-
- const fetchFiles = async () => {
- setLoading(true);
- try {
- const response = await api.getFiles(selectedProject.name);
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error('❌ File fetch failed:', response.status, errorText);
- setFiles([]);
- return;
- }
-
- const data = await response.json();
- setFiles(data);
- } catch (error) {
- console.error('❌ Error fetching files:', error);
- setFiles([]);
- } finally {
- setLoading(false);
- }
- };
-
- const toggleDirectory = (path) => {
- const newExpanded = new Set(expandedDirs);
- if (newExpanded.has(path)) {
- newExpanded.delete(path);
- } else {
- newExpanded.add(path);
- }
- setExpandedDirs(newExpanded);
- };
-
- const changeViewMode = (mode) => {
- setViewMode(mode);
- localStorage.setItem('file-tree-view-mode', mode);
- };
-
- const formatFileSize = (bytes) => {
- if (!bytes || bytes === 0) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
- };
-
- const formatRelativeTime = (date) => {
- if (!date) return '-';
- const now = new Date();
- const past = new Date(date);
- const diffInSeconds = Math.floor((now - past) / 1000);
-
- if (diffInSeconds < 60) return t('fileTree.justNow');
- if (diffInSeconds < 3600) return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
- if (diffInSeconds < 86400) return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
- if (diffInSeconds < 2592000) return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
- return past.toLocaleDateString();
- };
-
- const isImageFile = (filename) => {
- const ext = filename.split('.').pop()?.toLowerCase();
- const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
- return imageExtensions.includes(ext);
- };
-
- const getFileIcon = (filename) => {
- const { icon: Icon, color } = getFileIconData(filename);
- return
;
- };
-
- // ── Click handler shared across all view modes ──
- const handleItemClick = (item) => {
- if (item.type === 'directory') {
- toggleDirectory(item.path);
- } else if (isImageFile(item.name)) {
- setSelectedImage({
- name: item.name,
- path: item.path,
- projectPath: selectedProject.path,
- projectName: selectedProject.name
- });
- } else if (onFileOpen) {
- onFileOpen(item.path);
- }
- };
-
- // ── Indent guide + folder/file icon rendering ──
- const renderIndentGuides = (level) => {
- if (level === 0) return null;
- return (
-
- {Array.from({ length: level }).map((_, i) => (
-
- ))}
-
- );
- };
-
- const renderItemIcons = (item) => {
- const isDir = item.type === 'directory';
- const isOpen = expandedDirs.has(item.path);
-
- if (isDir) {
- return (
-
-
- {isOpen ? (
-
- ) : (
-
- )}
-
- );
- }
-
- return (
-
- {getFileIcon(item.name)}
-
- );
- };
-
- // ─── Simple (Tree) View ────────────────────────────────────────────
- const renderFileTree = (items, level = 0) => {
- return items.map((item) => {
- const isDir = item.type === 'directory';
- const isOpen = isDir && expandedDirs.has(item.path);
-
- return (
-
-
handleItemClick(item)}
- >
- {renderItemIcons(item)}
-
- {item.name}
-
-
-
- {isDir && isOpen && item.children && item.children.length > 0 && (
-
-
- {renderFileTree(item.children, level + 1)}
-
- )}
-
- );
- });
- };
-
- // ─── Detailed View ────────────────────────────────────────────────
- const renderDetailedView = (items, level = 0) => {
- return items.map((item) => {
- const isDir = item.type === 'directory';
- const isOpen = isDir && expandedDirs.has(item.path);
-
- return (
-
-
handleItemClick(item)}
- >
-
- {renderItemIcons(item)}
-
- {item.name}
-
-
-
- {item.type === 'file' ? formatFileSize(item.size) : ''}
-
-
- {formatRelativeTime(item.modified)}
-
-
- {item.permissionsRwx || ''}
-
-
-
- {isDir && isOpen && item.children && (
-
-
- {renderDetailedView(item.children, level + 1)}
-
- )}
-
- );
- });
- };
-
- // ─── Compact View ──────────────────────────────────────────────────
- const renderCompactView = (items, level = 0) => {
- return items.map((item) => {
- const isDir = item.type === 'directory';
- const isOpen = isDir && expandedDirs.has(item.path);
-
- return (
-
-
handleItemClick(item)}
- >
-
- {renderItemIcons(item)}
-
- {item.name}
-
-
-
- {item.type === 'file' && (
- <>
- {formatFileSize(item.size)}
- {item.permissionsRwx}
- >
- )}
-
-
-
- {isDir && isOpen && item.children && (
-
-
- {renderCompactView(item.children, level + 1)}
-
- )}
-
- );
- });
- };
-
- // ─── Loading state ─────────────────────────────────────────────────
- if (loading) {
- return (
-
-
- {t('fileTree.loading')}
-
-
- );
- }
-
- // ─── Main render ───────────────────────────────────────────────────
- return (
-
- {/* Header */}
-
-
-
- {t('fileTree.files')}
-
-
-
changeViewMode('simple')}
- title={t('fileTree.simpleView')}
- >
-
-
-
changeViewMode('compact')}
- title={t('fileTree.compactView')}
- >
-
-
-
changeViewMode('detailed')}
- title={t('fileTree.detailedView')}
- >
-
-
-
-
-
- {/* Search Bar */}
-
-
- setSearchQuery(e.target.value)}
- className="pl-8 pr-8 h-8 text-sm"
- />
- {searchQuery && (
- setSearchQuery('')}
- title={t('fileTree.clearSearch')}
- >
-
-
- )}
-
-
-
- {/* Column Headers for Detailed View */}
- {viewMode === 'detailed' && filteredFiles.length > 0 && (
-
-
-
{t('fileTree.name')}
-
{t('fileTree.size')}
-
{t('fileTree.modified')}
-
{t('fileTree.permissions')}
-
-
- )}
-
-
- {files.length === 0 ? (
-
-
-
-
-
{t('fileTree.noFilesFound')}
-
- {t('fileTree.checkProjectPath')}
-
-
- ) : filteredFiles.length === 0 && searchQuery ? (
-
-
-
-
-
{t('fileTree.noMatchesFound')}
-
- {t('fileTree.tryDifferentSearch')}
-
-
- ) : (
-
- {viewMode === 'simple' && renderFileTree(filteredFiles)}
- {viewMode === 'compact' && renderCompactView(filteredFiles)}
- {viewMode === 'detailed' && renderDetailedView(filteredFiles)}
-
- )}
-
-
- {/* Image Viewer Modal */}
- {selectedImage && (
-
setSelectedImage(null)}
- />
- )}
-
- );
-}
-
-export default FileTree;
diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx
deleted file mode 100644
index 76690fb..0000000
--- a/src/components/GitPanel.jsx
+++ /dev/null
@@ -1,1426 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
-import { MicButton } from './MicButton.jsx';
-import { authenticatedFetch } from '../utils/api';
-import DiffViewer from './DiffViewer.jsx';
-
-function GitPanel({ selectedProject, isMobile, onFileOpen }) {
- const [gitStatus, setGitStatus] = useState(null);
- const [gitDiff, setGitDiff] = useState({});
- const [isLoading, setIsLoading] = useState(false);
- const [commitMessage, setCommitMessage] = useState('');
- const [expandedFiles, setExpandedFiles] = useState(new Set());
- const [selectedFiles, setSelectedFiles] = useState(new Set());
- const [isCommitting, setIsCommitting] = useState(false);
- const [currentBranch, setCurrentBranch] = useState('');
- const [branches, setBranches] = useState([]);
- const [wrapText, setWrapText] = useState(true);
- const [showLegend, setShowLegend] = useState(false);
- const [showBranchDropdown, setShowBranchDropdown] = useState(false);
- const [showNewBranchModal, setShowNewBranchModal] = useState(false);
- const [newBranchName, setNewBranchName] = useState('');
- const [isCreatingBranch, setIsCreatingBranch] = useState(false);
- const [activeView, setActiveView] = useState('changes'); // 'changes' or 'history'
- const [recentCommits, setRecentCommits] = useState([]);
- const [expandedCommits, setExpandedCommits] = useState(new Set());
- const [commitDiffs, setCommitDiffs] = useState({});
- const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
- const [remoteStatus, setRemoteStatus] = useState(null);
- const [isFetching, setIsFetching] = useState(false);
- const [isPulling, setIsPulling] = useState(false);
- const [isPushing, setIsPushing] = useState(false);
- const [isPublishing, setIsPublishing] = useState(false);
- const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
- const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
- const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
- const textareaRef = useRef(null);
- const dropdownRef = useRef(null);
-
- // Get current provider from localStorage (same as ChatInterface does)
- const [provider, setProvider] = useState(() => {
- return localStorage.getItem('selected-provider') || 'claude';
- });
-
- // Listen for provider changes in localStorage
- useEffect(() => {
- const handleStorageChange = () => {
- const newProvider = localStorage.getItem('selected-provider') || 'claude';
- setProvider(newProvider);
- };
-
- window.addEventListener('storage', handleStorageChange);
- return () => window.removeEventListener('storage', handleStorageChange);
- }, []);
-
- useEffect(() => {
- // Clear stale repo-scoped state when project changes.
- setCurrentBranch('');
- setBranches([]);
- setGitStatus(null);
- setRemoteStatus(null);
- setSelectedFiles(new Set());
-
- if (!selectedProject) {
- return;
- }
-
- fetchGitStatus();
- fetchBranches();
- fetchRemoteStatus();
- }, [selectedProject]);
-
- useEffect(() => {
- if (!selectedProject || activeView !== 'history') {
- return;
- }
-
- fetchRecentCommits();
- }, [selectedProject, activeView]);
-
- // Handle click outside dropdown
- useEffect(() => {
- const handleClickOutside = (event) => {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
- setShowBranchDropdown(false);
- }
- };
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, []);
-
- const fetchGitStatus = async () => {
- if (!selectedProject) return;
-
-
- setIsLoading(true);
- try {
- const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
- const data = await response.json();
-
-
- if (data.error) {
- console.error('Git status error:', data.error);
- setGitStatus({ error: data.error, details: data.details });
- setCurrentBranch('');
- setSelectedFiles(new Set());
- } else {
- setGitStatus(data);
- setCurrentBranch(data.branch || 'main');
-
- // Auto-select all changed files
- const allFiles = new Set([
- ...(data.modified || []),
- ...(data.added || []),
- ...(data.deleted || []),
- ...(data.untracked || [])
- ]);
- setSelectedFiles(allFiles);
-
- // Fetch diffs for changed files
- for (const file of data.modified || []) {
- fetchFileDiff(file);
- }
- for (const file of data.added || []) {
- fetchFileDiff(file);
- }
- for (const file of data.deleted || []) {
- fetchFileDiff(file);
- }
- for (const file of data.untracked || []) {
- fetchFileDiff(file);
- }
- }
- } catch (error) {
- console.error('Error fetching git status:', error);
- setGitStatus({ error: 'Git operation failed', details: String(error) });
- setCurrentBranch('');
- setSelectedFiles(new Set());
- } finally {
- setIsLoading(false);
- }
- };
-
- const fetchBranches = async () => {
- try {
- const response = await authenticatedFetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
- const data = await response.json();
-
- if (!data.error && data.branches) {
- setBranches(data.branches);
- } else {
- setBranches([]);
- }
- } catch (error) {
- console.error('Error fetching branches:', error);
- setBranches([]);
- }
- };
-
- const fetchRemoteStatus = async () => {
- if (!selectedProject) return;
-
- try {
- const response = await authenticatedFetch(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
- const data = await response.json();
-
- if (!data.error) {
- setRemoteStatus(data);
- } else {
- setRemoteStatus(null);
- }
- } catch (error) {
- console.error('Error fetching remote status:', error);
- setRemoteStatus(null);
- }
- };
-
- const switchBranch = async (branchName) => {
- try {
- const response = await authenticatedFetch('/api/git/checkout', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name,
- branch: branchName
- })
- });
-
- const data = await response.json();
- if (data.success) {
- setCurrentBranch(branchName);
- setShowBranchDropdown(false);
- fetchGitStatus(); // Refresh status after branch switch
- } else {
- console.error('Failed to switch branch:', data.error);
- }
- } catch (error) {
- console.error('Error switching branch:', error);
- }
- };
-
- const createBranch = async () => {
- if (!newBranchName.trim()) return;
-
- setIsCreatingBranch(true);
- try {
- const response = await authenticatedFetch('/api/git/create-branch', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name,
- branch: newBranchName.trim()
- })
- });
-
- const data = await response.json();
- if (data.success) {
- setCurrentBranch(newBranchName.trim());
- setShowNewBranchModal(false);
- setShowBranchDropdown(false);
- setNewBranchName('');
- fetchBranches(); // Refresh branch list
- fetchGitStatus(); // Refresh status
- } else {
- console.error('Failed to create branch:', data.error);
- }
- } catch (error) {
- console.error('Error creating branch:', error);
- } finally {
- setIsCreatingBranch(false);
- }
- };
-
- const handleFetch = async () => {
- setIsFetching(true);
- try {
- const response = await authenticatedFetch('/api/git/fetch', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name
- })
- });
-
- const data = await response.json();
- if (data.success) {
- // Refresh status after successful fetch
- fetchGitStatus();
- fetchRemoteStatus();
- } else {
- console.error('Fetch failed:', data.error);
- }
- } catch (error) {
- console.error('Error fetching from remote:', error);
- } finally {
- setIsFetching(false);
- }
- };
-
- const handlePull = async () => {
- setIsPulling(true);
- try {
- const response = await authenticatedFetch('/api/git/pull', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name
- })
- });
-
- const data = await response.json();
- if (data.success) {
- // Refresh status after successful pull
- fetchGitStatus();
- fetchRemoteStatus();
- } else {
- console.error('Pull failed:', data.error);
- // TODO: Show user-friendly error message
- }
- } catch (error) {
- console.error('Error pulling from remote:', error);
- } finally {
- setIsPulling(false);
- }
- };
-
- const handlePush = async () => {
- setIsPushing(true);
- try {
- const response = await authenticatedFetch('/api/git/push', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name
- })
- });
-
- const data = await response.json();
- if (data.success) {
- // Refresh status after successful push
- fetchGitStatus();
- fetchRemoteStatus();
- } else {
- console.error('Push failed:', data.error);
- // TODO: Show user-friendly error message
- }
- } catch (error) {
- console.error('Error pushing to remote:', error);
- } finally {
- setIsPushing(false);
- }
- };
-
- const handlePublish = async () => {
- setIsPublishing(true);
- try {
- const response = await authenticatedFetch('/api/git/publish', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name,
- branch: currentBranch
- })
- });
-
- const data = await response.json();
- if (data.success) {
- // Refresh status after successful publish
- fetchGitStatus();
- fetchRemoteStatus();
- } else {
- console.error('Publish failed:', data.error);
- // TODO: Show user-friendly error message
- }
- } catch (error) {
- console.error('Error publishing branch:', error);
- } finally {
- setIsPublishing(false);
- }
- };
-
- const discardChanges = async (filePath) => {
- try {
- const response = await authenticatedFetch('/api/git/discard', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name,
- file: filePath
- })
- });
-
- const data = await response.json();
- if (data.success) {
- // Remove from selected files and refresh status
- setSelectedFiles(prev => {
- const newSet = new Set(prev);
- newSet.delete(filePath);
- return newSet;
- });
- fetchGitStatus();
- } else {
- console.error('Discard failed:', data.error);
- }
- } catch (error) {
- console.error('Error discarding changes:', error);
- }
- };
-
- const deleteUntrackedFile = async (filePath) => {
- try {
- const response = await authenticatedFetch('/api/git/delete-untracked', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name,
- file: filePath
- })
- });
-
- const data = await response.json();
- if (data.success) {
- // Remove from selected files and refresh status
- setSelectedFiles(prev => {
- const newSet = new Set(prev);
- newSet.delete(filePath);
- return newSet;
- });
- fetchGitStatus();
- } else {
- console.error('Delete failed:', data.error);
- }
- } catch (error) {
- console.error('Error deleting untracked file:', error);
- }
- };
-
- const confirmAndExecute = async () => {
- if (!confirmAction) return;
-
- const { type, file, message } = confirmAction;
- setConfirmAction(null);
-
- try {
- switch (type) {
- case 'discard':
- await discardChanges(file);
- break;
- case 'delete':
- await deleteUntrackedFile(file);
- break;
- case 'commit':
- await handleCommit();
- break;
- case 'pull':
- await handlePull();
- break;
- case 'push':
- await handlePush();
- break;
- case 'publish':
- await handlePublish();
- break;
- }
- } catch (error) {
- console.error(`Error executing ${type}:`, error);
- }
- };
-
- const fetchFileDiff = async (filePath) => {
- try {
- const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
- const data = await response.json();
-
- if (!data.error && data.diff) {
- setGitDiff(prev => ({
- ...prev,
- [filePath]: data.diff
- }));
- }
- } catch (error) {
- console.error('Error fetching file diff:', error);
- }
- };
-
- const handleFileOpen = async (filePath) => {
- if (!onFileOpen) return;
-
- try {
- // Fetch file content with diff information
- const response = await authenticatedFetch(`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
- const data = await response.json();
-
- if (data.error) {
- console.error('Error fetching file with diff:', data.error);
- // Fallback: open without diff info
- onFileOpen(filePath);
- return;
- }
-
- // Create diffInfo object for CodeEditor
- const diffInfo = {
- old_string: data.oldContent || '',
- new_string: data.currentContent || ''
- };
-
- // Open file with diff information
- onFileOpen(filePath, diffInfo);
- } catch (error) {
- console.error('Error opening file:', error);
- // Fallback: open without diff info
- onFileOpen(filePath);
- }
- };
-
- const fetchRecentCommits = async () => {
- try {
- const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
- const data = await response.json();
-
- if (!data.error && data.commits) {
- setRecentCommits(data.commits);
- }
- } catch (error) {
- console.error('Error fetching commits:', error);
- }
- };
-
- const fetchCommitDiff = async (commitHash) => {
- try {
- const response = await authenticatedFetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
- const data = await response.json();
-
- if (!data.error && data.diff) {
- setCommitDiffs(prev => ({
- ...prev,
- [commitHash]: data.diff
- }));
- }
- } catch (error) {
- console.error('Error fetching commit diff:', error);
- }
- };
-
- const generateCommitMessage = async () => {
- setIsGeneratingMessage(true);
- try {
- const response = await authenticatedFetch('/api/git/generate-commit-message', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name,
- files: Array.from(selectedFiles),
- provider: provider // Pass the current provider (claude or cursor)
- })
- });
-
- const data = await response.json();
- if (data.message) {
- setCommitMessage(data.message);
- } else {
- console.error('Failed to generate commit message:', data.error);
- }
- } catch (error) {
- console.error('Error generating commit message:', error);
- } finally {
- setIsGeneratingMessage(false);
- }
- };
-
- const toggleFileExpanded = (filePath) => {
- setExpandedFiles(prev => {
- const newSet = new Set(prev);
- if (newSet.has(filePath)) {
- newSet.delete(filePath);
- } else {
- newSet.add(filePath);
- }
- return newSet;
- });
- };
-
- const toggleCommitExpanded = (commitHash) => {
- setExpandedCommits(prev => {
- const newSet = new Set(prev);
- if (newSet.has(commitHash)) {
- newSet.delete(commitHash);
- } else {
- newSet.add(commitHash);
- // Fetch diff for this commit if not already fetched
- if (!commitDiffs[commitHash]) {
- fetchCommitDiff(commitHash);
- }
- }
- return newSet;
- });
- };
-
- const toggleFileSelected = (filePath) => {
- setSelectedFiles(prev => {
- const newSet = new Set(prev);
- if (newSet.has(filePath)) {
- newSet.delete(filePath);
- } else {
- newSet.add(filePath);
- }
- return newSet;
- });
- };
-
- const handleCommit = async () => {
- if (!commitMessage.trim() || selectedFiles.size === 0) return;
-
- setIsCommitting(true);
- try {
- const response = await authenticatedFetch('/api/git/commit', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name,
- message: commitMessage,
- files: Array.from(selectedFiles)
- })
- });
-
- const data = await response.json();
- if (data.success) {
- // Reset state after successful commit
- setCommitMessage('');
- setSelectedFiles(new Set());
- fetchGitStatus();
- fetchRemoteStatus();
- } else {
- console.error('Commit failed:', data.error);
- }
- } catch (error) {
- console.error('Error committing changes:', error);
- } finally {
- setIsCommitting(false);
- }
- };
-
- const createInitialCommit = async () => {
- setIsCreatingInitialCommit(true);
- try {
- const response = await authenticatedFetch('/api/git/initial-commit', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- project: selectedProject.name
- })
- });
-
- const data = await response.json();
- if (data.success) {
- fetchGitStatus();
- fetchRemoteStatus();
- } else {
- console.error('Initial commit failed:', data.error);
- alert(data.error || 'Failed to create initial commit');
- }
- } catch (error) {
- console.error('Error creating initial commit:', error);
- alert('Failed to create initial commit');
- } finally {
- setIsCreatingInitialCommit(false);
- }
- };
-
- const getStatusLabel = (status) => {
- switch (status) {
- case 'M': return 'Modified';
- case 'A': return 'Added';
- case 'D': return 'Deleted';
- case 'U': return 'Untracked';
- default: return status;
- }
- };
-
- const renderCommitItem = (commit) => {
- const isExpanded = expandedCommits.has(commit.hash);
- const diff = commitDiffs[commit.hash];
-
- return (
-
-
toggleCommitExpanded(commit.hash)}
- >
-
- {isExpanded ? : }
-
-
-
-
-
- {commit.message}
-
-
- {commit.author} • {commit.date}
-
-
-
- {commit.hash.substring(0, 7)}
-
-
-
-
- {isExpanded && diff && (
-
- )}
-
- );
- };
-
- const renderFileItem = (filePath, status) => {
- const isExpanded = expandedFiles.has(filePath);
- const isSelected = selectedFiles.has(filePath);
- const diff = gitDiff[filePath];
-
- return (
-
-
-
toggleFileSelected(filePath)}
- onClick={(e) => e.stopPropagation()}
- className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
- />
-
-
{
- e.stopPropagation();
- toggleFileExpanded(filePath);
- }}
- >
-
-
-
{
- e.stopPropagation();
- handleFileOpen(filePath);
- }}
- title="Click to open file"
- >
- {filePath}
-
-
- {(status === 'M' || status === 'D') && (
- {
- e.stopPropagation();
- setConfirmAction({
- type: 'discard',
- file: filePath,
- message: `Discard all changes to "${filePath}"? This action cannot be undone.`
- });
- }}
- className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
- title="Discard changes"
- >
-
- {isMobile && Discard }
-
- )}
- {status === 'U' && (
- {
- e.stopPropagation();
- setConfirmAction({
- type: 'delete',
- file: filePath,
- message: `Delete untracked file "${filePath}"? This action cannot be undone.`
- });
- }}
- className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
- title="Delete untracked file"
- >
-
- {isMobile && Delete }
-
- )}
-
- {status}
-
-
-
-
-
- {/* Operation header */}
-
-
-
- {status}
-
-
- {getStatusLabel(status)}
-
-
- {isMobile && (
-
{
- e.stopPropagation();
- setWrapText(!wrapText);
- }}
- className="text-sm text-muted-foreground hover:text-foreground transition-colors"
- title={wrapText ? "Switch to horizontal scroll" : "Switch to text wrap"}
- >
- {wrapText ? '↔️ Scroll' : '↩️ Wrap'}
-
- )}
-
-
- {diff && }
-
-
-
- );
- };
-
- if (!selectedProject) {
- return (
-
-
Select a project to view source control
-
- );
- }
-
- return (
-
- {/* Header */}
-
-
-
setShowBranchDropdown(!showBranchDropdown)}
- className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
- >
-
-
-
{currentBranch}
- {/* Remote status indicators */}
- {remoteStatus?.hasRemote && (
-
- {remoteStatus.ahead > 0 && (
-
- ↑{remoteStatus.ahead}
-
- )}
- {remoteStatus.behind > 0 && (
-
- ↓{remoteStatus.behind}
-
- )}
- {remoteStatus.isUpToDate && (
-
- ✓
-
- )}
-
- )}
-
-
-
-
- {/* Branch Dropdown */}
- {showBranchDropdown && (
-
-
- {branches.map(branch => (
-
switchBranch(branch)}
- className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
- branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
- }`}
- >
-
- {branch === currentBranch && }
- {branch}
-
-
- ))}
-
-
-
{
- setShowNewBranchModal(true);
- setShowBranchDropdown(false);
- }}
- className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
- >
-
- Create new branch
-
-
-
- )}
-
-
-
- {/* Remote action buttons - smart logic based on ahead/behind status */}
- {remoteStatus?.hasRemote && (
- <>
- {/* Publish button - show when branch doesn't exist on remote */}
- {!remoteStatus?.hasUpstream && (
- setConfirmAction({
- type: 'publish',
- message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
- })}
- disabled={isPublishing}
- className="px-2.5 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
- title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
- >
-
- {isPublishing ? 'Publishing...' : 'Publish'}
-
- )}
-
- {/* Show normal push/pull buttons only if branch has upstream */}
- {remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
- <>
- {/* Pull button - show when behind (primary action) */}
- {remoteStatus.behind > 0 && (
- setConfirmAction({
- type: 'pull',
- message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
- })}
- disabled={isPulling}
- className="px-2.5 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
- title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
- >
-
- {isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}
-
- )}
-
- {/* Push button - show when ahead (primary action when ahead only) */}
- {remoteStatus.ahead > 0 && (
- setConfirmAction({
- type: 'push',
- message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
- })}
- disabled={isPushing}
- className="px-2.5 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
- title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
- >
-
- {isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}
-
- )}
-
- {/* Fetch button - show when ahead only or when diverged (secondary action) */}
- {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
-
-
- {isFetching ? 'Fetching...' : 'Fetch'}
-
- )}
- >
- )}
- >
- )}
-
- {
- fetchGitStatus();
- fetchBranches();
- fetchRemoteStatus();
- }}
- disabled={isLoading}
- className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
- >
-
-
-
-
-
- {/* Git Repository Not Found Message */}
- {gitStatus?.error ? (
-
-
-
-
-
{gitStatus.error}
- {gitStatus.details && (
-
{gitStatus.details}
- )}
-
-
- Tip: Run git init in your project directory to initialize git source control.
-
-
-
- ) : (
- <>
- {/* Tab Navigation - Only show when git is available and no files expanded */}
-
-
setActiveView('changes')}
- className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
- activeView === 'changes'
- ? 'text-primary border-b-2 border-primary'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
-
-
- Changes
-
-
-
setActiveView('history')}
- className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
- activeView === 'history'
- ? 'text-primary border-b-2 border-primary'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
-
-
- History
-
-
-
-
- {/* Changes View */}
- {activeView === 'changes' && (
- <>
- {/* Mobile Commit Toggle Button / Desktop Always Visible - Hide when files expanded */}
-
- {isMobile && isCommitAreaCollapsed ? (
-
- setIsCommitAreaCollapsed(false)}
- className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
- >
-
- Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}
-
-
-
- ) : (
- <>
- {/* Commit Message Input */}
-
- {/* Mobile collapse button */}
- {isMobile && (
-
- Commit Changes
- setIsCommitAreaCollapsed(true)}
- className="p-1 hover:bg-accent rounded-lg transition-colors"
- >
-
-
-
- )}
-
-
-
-
- {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
-
- setConfirmAction({
- type: 'commit',
- message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
- })}
- disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
- className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1 transition-colors"
- >
-
- {isCommitting ? 'Committing...' : 'Commit'}
-
-
-
- >
- )}
-
- >
- )}
-
- {/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
- {activeView === 'changes' && gitStatus && !gitStatus.error && (
-
-
- {selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
-
-
- {
- const allFiles = new Set([
- ...(gitStatus?.modified || []),
- ...(gitStatus?.added || []),
- ...(gitStatus?.deleted || []),
- ...(gitStatus?.untracked || [])
- ]);
- setSelectedFiles(allFiles);
- }}
- className="text-sm text-primary hover:text-primary/80 transition-colors"
- >
- {isMobile ? 'All' : 'Select All'}
-
- |
- setSelectedFiles(new Set())}
- className="text-sm text-primary hover:text-primary/80 transition-colors"
- >
- {isMobile ? 'None' : 'Deselect All'}
-
-
-
- )}
-
- {/* Status Legend Toggle - Hide on mobile by default */}
- {!gitStatus?.error && !isMobile && (
-
-
setShowLegend(!showLegend)}
- className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-sm text-muted-foreground flex items-center justify-center gap-1 transition-colors"
- >
-
- File Status Guide
- {showLegend ? : }
-
-
- {showLegend && (
-
-
-
-
- M
-
- Modified
-
-
-
- A
-
- Added
-
-
-
- D
-
- Deleted
-
-
-
- U
-
- Untracked
-
-
-
- )}
-
- )}
- >
- )}
-
- {/* File List - Changes View - Only show when git is available */}
- {activeView === 'changes' && !gitStatus?.error && (
-
- {isLoading ? (
-
-
-
- ) : gitStatus?.hasCommits === false ? (
-
-
-
-
-
No commits yet
-
- This repository doesn't have any commits yet. Create your first commit to start tracking changes.
-
-
- {isCreatingInitialCommit ? (
- <>
-
- Creating Initial Commit...
- >
- ) : (
- <>
-
- Create Initial Commit
- >
- )}
-
-
- ) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
-
-
-
No changes detected
-
- ) : (
-
- {gitStatus.modified?.map(file => renderFileItem(file, 'M'))}
- {gitStatus.added?.map(file => renderFileItem(file, 'A'))}
- {gitStatus.deleted?.map(file => renderFileItem(file, 'D'))}
- {gitStatus.untracked?.map(file => renderFileItem(file, 'U'))}
-
- )}
-
- )}
-
- {/* History View - Only show when git is available */}
- {activeView === 'history' && !gitStatus?.error && (
-
- {isLoading ? (
-
-
-
- ) : recentCommits.length === 0 ? (
-
- ) : (
-
- {recentCommits.map(commit => renderCommitItem(commit))}
-
- )}
-
- )}
-
- {/* New Branch Modal */}
- {showNewBranchModal && (
-
-
setShowNewBranchModal(false)} />
-
-
-
Create New Branch
-
-
- Branch Name
-
- setNewBranchName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !isCreatingBranch) {
- createBranch();
- }
- }}
- placeholder="feature/new-feature"
- className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
- autoFocus
- />
-
-
- This will create a new branch from the current branch ({currentBranch})
-
-
-
{
- setShowNewBranchModal(false);
- setNewBranchName('');
- }}
- className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
- >
- Cancel
-
-
- {isCreatingBranch ? (
- <>
-
- Creating...
- >
- ) : (
- <>
-
- Create Branch
- >
- )}
-
-
-
-
-
- )}
-
- {/* Confirmation Modal */}
- {confirmAction && (
-
-
setConfirmAction(null)} />
-
-
-
-
-
- {confirmAction.type === 'discard' ? 'Discard Changes' :
- confirmAction.type === 'delete' ? 'Delete File' :
- confirmAction.type === 'commit' ? 'Confirm Commit' :
- confirmAction.type === 'pull' ? 'Confirm Pull' :
- confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
-
-
-
-
- {confirmAction.message}
-
-
-
- setConfirmAction(null)}
- className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
- >
- Cancel
-
-
- {confirmAction.type === 'discard' ? (
- <>
-
- Discard
- >
- ) : confirmAction.type === 'delete' ? (
- <>
-
- Delete
- >
- ) : confirmAction.type === 'commit' ? (
- <>
-
- Commit
- >
- ) : confirmAction.type === 'pull' ? (
- <>
-
- Pull
- >
- ) : confirmAction.type === 'publish' ? (
- <>
-
- Publish
- >
- ) : (
- <>
-
- Push
- >
- )}
-
-
-
-
-
- )}
-
- );
-}
-
-export default GitPanel;
diff --git a/src/components/GitSettings.jsx b/src/components/GitSettings.jsx
deleted file mode 100644
index cdce8e4..0000000
--- a/src/components/GitSettings.jsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useState, useEffect } from 'react';
-import { Button } from './ui/button';
-import { Input } from './ui/input';
-import { GitBranch, Check } from 'lucide-react';
-import { authenticatedFetch } from '../utils/api';
-import { useTranslation } from 'react-i18next';
-
-function GitSettings() {
- const { t } = useTranslation('settings');
- const [gitName, setGitName] = useState('');
- const [gitEmail, setGitEmail] = useState('');
- const [gitConfigLoading, setGitConfigLoading] = useState(false);
- const [gitConfigSaving, setGitConfigSaving] = useState(false);
- const [saveStatus, setSaveStatus] = useState(null);
-
- useEffect(() => {
- loadGitConfig();
- }, []);
-
- const loadGitConfig = async () => {
- try {
- setGitConfigLoading(true);
- const response = await authenticatedFetch('/api/user/git-config');
- if (response.ok) {
- const data = await response.json();
- setGitName(data.gitName || '');
- setGitEmail(data.gitEmail || '');
- }
- } catch (error) {
- console.error('Error loading git config:', error);
- } finally {
- setGitConfigLoading(false);
- }
- };
-
- const saveGitConfig = async () => {
- try {
- setGitConfigSaving(true);
- const response = await authenticatedFetch('/api/user/git-config', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ gitName, gitEmail })
- });
-
- if (response.ok) {
- setSaveStatus('success');
- setTimeout(() => setSaveStatus(null), 3000);
- } else {
- const data = await response.json();
- setSaveStatus('error');
- console.error('Failed to save git config:', data.error);
- }
- } catch (error) {
- console.error('Error saving git config:', error);
- setSaveStatus('error');
- } finally {
- setGitConfigSaving(false);
- }
- };
-
- return (
-
-
-
-
-
{t('git.title')}
-
-
-
- {t('git.description')}
-
-
-
-
-
- {t('git.name.label')}
-
-
setGitName(e.target.value)}
- placeholder="John Doe"
- disabled={gitConfigLoading}
- className="w-full"
- />
-
- {t('git.name.help')}
-
-
-
-
-
- {t('git.email.label')}
-
-
setGitEmail(e.target.value)}
- placeholder="john@example.com"
- disabled={gitConfigLoading}
- className="w-full"
- />
-
- {t('git.email.help')}
-
-
-
-
-
- {gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')}
-
-
- {saveStatus === 'success' && (
-
-
- {t('git.status.success')}
-
- )}
-
-
-
-
- );
-}
-
-export default GitSettings;
diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx
index 5c3af51..f391c6d 100644
--- a/src/components/LoginModal.jsx
+++ b/src/components/LoginModal.jsx
@@ -1,5 +1,5 @@
import { X } from 'lucide-react';
-import StandaloneShell from './StandaloneShell';
+import StandaloneShell from './standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
diff --git a/src/components/MicButton.jsx b/src/components/MicButton.jsx
deleted file mode 100644
index 2e82e65..0000000
--- a/src/components/MicButton.jsx
+++ /dev/null
@@ -1,272 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { Mic, Loader2, Brain } from 'lucide-react';
-import { transcribeWithWhisper } from '../utils/whisper';
-
-export function MicButton({ onTranscript, className = '' }) {
- const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
- const [error, setError] = useState(null);
- const [isSupported, setIsSupported] = useState(true);
-
- const mediaRecorderRef = useRef(null);
- const streamRef = useRef(null);
- const chunksRef = useRef([]);
- const lastTapRef = useRef(0);
-
- // Check microphone support on mount
- useEffect(() => {
- const checkSupport = () => {
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- setIsSupported(false);
- setError('Microphone not supported. Please use HTTPS or a modern browser.');
- return;
- }
-
- // Additional check for secure context
- if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
- setIsSupported(false);
- setError('Microphone requires HTTPS. Please use a secure connection.');
- return;
- }
-
- setIsSupported(true);
- setError(null);
- };
-
- checkSupport();
- }, []);
-
- // Start recording
- const startRecording = async () => {
- try {
- console.log('Starting recording...');
- setError(null);
- chunksRef.current = [];
-
- // Check if getUserMedia is available
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
- }
-
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- streamRef.current = stream;
-
- const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
- const recorder = new MediaRecorder(stream, { mimeType });
- mediaRecorderRef.current = recorder;
-
- recorder.ondataavailable = (e) => {
- if (e.data.size > 0) {
- chunksRef.current.push(e.data);
- }
- };
-
- recorder.onstop = async () => {
- console.log('Recording stopped, creating blob...');
- const blob = new Blob(chunksRef.current, { type: mimeType });
-
- // Clean up stream
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- streamRef.current = null;
- }
-
- // Start transcribing
- setState('transcribing');
-
- // Check if we're in an enhancement mode
- const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
- const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
-
- // Set up a timer to switch to processing state for enhancement modes
- let processingTimer;
- if (isEnhancementMode) {
- processingTimer = setTimeout(() => {
- setState('processing');
- }, 2000); // Switch to processing after 2 seconds
- }
-
- try {
- const text = await transcribeWithWhisper(blob);
- if (text && onTranscript) {
- onTranscript(text);
- }
- } catch (err) {
- console.error('Transcription error:', err);
- setError(err.message);
- } finally {
- if (processingTimer) {
- clearTimeout(processingTimer);
- }
- setState('idle');
- }
- };
-
- recorder.start();
- setState('recording');
- console.log('Recording started successfully');
- } catch (err) {
- console.error('Failed to start recording:', err);
-
- // Provide specific error messages based on error type
- let errorMessage = 'Microphone access failed';
-
- if (err.name === 'NotAllowedError') {
- errorMessage = 'Microphone access denied. Please allow microphone permissions.';
- } else if (err.name === 'NotFoundError') {
- errorMessage = 'No microphone found. Please check your audio devices.';
- } else if (err.name === 'NotSupportedError') {
- errorMessage = 'Microphone not supported by this browser.';
- } else if (err.name === 'NotReadableError') {
- errorMessage = 'Microphone is being used by another application.';
- } else if (err.message.includes('HTTPS')) {
- errorMessage = err.message;
- }
-
- setError(errorMessage);
- setState('idle');
- }
- };
-
- // Stop recording
- const stopRecording = () => {
- console.log('Stopping recording...');
- if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
- mediaRecorderRef.current.stop();
- // Don't set state here - let the onstop handler do it
- } else {
- // If recorder isn't in recording state, force cleanup
- console.log('Recorder not in recording state, forcing cleanup');
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- streamRef.current = null;
- }
- setState('idle');
- }
- };
-
- // Handle button click
- const handleClick = (e) => {
- // Prevent double firing on mobile
- if (e) {
- e.preventDefault();
- e.stopPropagation();
- }
-
- // Don't proceed if microphone is not supported
- if (!isSupported) {
- return;
- }
-
- // Debounce for mobile double-tap issue
- const now = Date.now();
- if (now - lastTapRef.current < 300) {
- console.log('Ignoring rapid tap');
- return;
- }
- lastTapRef.current = now;
-
- console.log('Button clicked, current state:', state);
-
- if (state === 'idle') {
- startRecording();
- } else if (state === 'recording') {
- stopRecording();
- }
- // Do nothing if transcribing or processing
- };
-
- // Clean up on unmount
- useEffect(() => {
- return () => {
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(track => track.stop());
- }
- };
- }, []);
-
- // Button appearance based on state
- const getButtonAppearance = () => {
- if (!isSupported) {
- return {
- icon:
,
- className: 'bg-gray-400 cursor-not-allowed',
- disabled: true
- };
- }
-
- switch (state) {
- case 'recording':
- return {
- icon:
,
- className: 'bg-red-500 hover:bg-red-600 animate-pulse',
- disabled: false
- };
- case 'transcribing':
- return {
- icon:
,
- className: 'bg-blue-500 hover:bg-blue-600',
- disabled: true
- };
- case 'processing':
- return {
- icon:
,
- className: 'bg-purple-500 hover:bg-purple-600',
- disabled: true
- };
- default: // idle
- return {
- icon:
,
- className: 'bg-gray-700 hover:bg-gray-600',
- disabled: false
- };
- }
- };
-
- const { icon, className: buttonClass, disabled } = getButtonAppearance();
-
- return (
-
-
- {icon}
-
-
- {error && (
-
- {error}
-
- )}
-
- {state === 'recording' && (
-
- )}
-
- {state === 'processing' && (
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/src/components/NextTaskBanner.jsx b/src/components/NextTaskBanner.jsx
index 2a55cb8..49eb941 100644
--- a/src/components/NextTaskBanner.jsx
+++ b/src/components/NextTaskBanner.jsx
@@ -3,7 +3,7 @@ import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause,
import { cn } from '../lib/utils';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { api } from '../utils/api';
-import Shell from './Shell';
+import Shell from './shell/view/Shell';
import TaskDetail from './TaskDetail';
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
diff --git a/src/components/Onboarding.jsx b/src/components/Onboarding.jsx
index 90a11af..bc9088e 100644
--- a/src/components/Onboarding.jsx
+++ b/src/components/Onboarding.jsx
@@ -1,8 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
-import ClaudeLogo from './ClaudeLogo';
-import CursorLogo from './CursorLogo';
-import CodexLogo from './CodexLogo';
+import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext';
@@ -347,7 +345,7 @@ const Onboarding = ({ onComplete }) => {
-
+
@@ -380,7 +378,7 @@ const Onboarding = ({ onComplete }) => {
-
+
@@ -413,7 +411,7 @@ const Onboarding = ({ onComplete }) => {
-
+
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx
deleted file mode 100644
index 9517257..0000000
--- a/src/components/Settings.jsx
+++ /dev/null
@@ -1,1977 +0,0 @@
-import { useState, useEffect } from 'react';
-import { Button } from './ui/button';
-import { Input } from './ui/input';
-import { Badge } from './ui/badge';
-import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key, GitBranch, Check } from 'lucide-react';
-import { useTheme } from '../contexts/ThemeContext';
-import { useTranslation } from 'react-i18next';
-import CredentialsSettings from './CredentialsSettings';
-import GitSettings from './GitSettings';
-import TasksSettings from './TasksSettings';
-import LoginModal from './LoginModal';
-import { authenticatedFetch } from '../utils/api';
-
-// New settings components
-import AgentListItem from './settings/AgentListItem';
-import AccountContent from './settings/AccountContent';
-import PermissionsContent from './settings/PermissionsContent';
-import McpServersContent from './settings/McpServersContent';
-import LanguageSelector from './LanguageSelector';
-
-function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
- const { isDarkMode, toggleDarkMode } = useTheme();
- const { t } = useTranslation('settings');
- const [allowedTools, setAllowedTools] = useState([]);
- const [disallowedTools, setDisallowedTools] = useState([]);
- const [newAllowedTool, setNewAllowedTool] = useState('');
- const [newDisallowedTool, setNewDisallowedTool] = useState('');
- const [skipPermissions, setSkipPermissions] = useState(false);
- const [isSaving, setIsSaving] = useState(false);
- const [saveStatus, setSaveStatus] = useState(null);
- const [projectSortOrder, setProjectSortOrder] = useState('name');
-
- const [mcpServers, setMcpServers] = useState([]);
- const [showMcpForm, setShowMcpForm] = useState(false);
- const [editingMcpServer, setEditingMcpServer] = useState(null);
- const [mcpFormData, setMcpFormData] = useState({
- name: '',
- type: 'stdio',
- scope: 'user',
- projectPath: '', // For local scope
- config: {
- command: '',
- args: [],
- env: {},
- url: '',
- headers: {},
- timeout: 30000
- },
- jsonInput: '', // For JSON import
- importMode: 'form' // 'form' or 'json'
- });
- const [mcpLoading, setMcpLoading] = useState(false);
- const [mcpTestResults, setMcpTestResults] = useState({});
- const [mcpServerTools, setMcpServerTools] = useState({});
- const [mcpToolsLoading, setMcpToolsLoading] = useState({});
- const [activeTab, setActiveTab] = useState(initialTab);
- const [jsonValidationError, setJsonValidationError] = useState('');
- const [selectedAgent, setSelectedAgent] = useState('claude'); // 'claude', 'cursor', or 'codex'
- const [selectedCategory, setSelectedCategory] = useState('account'); // 'account', 'permissions', or 'mcp'
-
- // Code Editor settings
- const [codeEditorTheme, setCodeEditorTheme] = useState(() =>
- localStorage.getItem('codeEditorTheme') || 'dark'
- );
- const [codeEditorWordWrap, setCodeEditorWordWrap] = useState(() =>
- localStorage.getItem('codeEditorWordWrap') === 'true'
- );
- const [codeEditorShowMinimap, setCodeEditorShowMinimap] = useState(() =>
- localStorage.getItem('codeEditorShowMinimap') !== 'false' // Default true
- );
- const [codeEditorLineNumbers, setCodeEditorLineNumbers] = useState(() =>
- localStorage.getItem('codeEditorLineNumbers') !== 'false' // Default true
- );
- const [codeEditorFontSize, setCodeEditorFontSize] = useState(() =>
- localStorage.getItem('codeEditorFontSize') || '14'
- );
-
- // Cursor-specific states
- const [cursorAllowedCommands, setCursorAllowedCommands] = useState([]);
- const [cursorDisallowedCommands, setCursorDisallowedCommands] = useState([]);
- const [cursorSkipPermissions, setCursorSkipPermissions] = useState(false);
- const [newCursorCommand, setNewCursorCommand] = useState('');
- const [newCursorDisallowedCommand, setNewCursorDisallowedCommand] = useState('');
- const [cursorMcpServers, setCursorMcpServers] = useState([]);
-
- // Codex-specific states
- const [codexMcpServers, setCodexMcpServers] = useState([]);
- const [codexPermissionMode, setCodexPermissionMode] = useState('default');
- const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
- const [codexMcpFormData, setCodexMcpFormData] = useState({
- name: '',
- type: 'stdio',
- config: {
- command: '',
- args: [],
- env: {}
- }
- });
- const [editingCodexMcpServer, setEditingCodexMcpServer] = useState(null);
- const [codexMcpLoading, setCodexMcpLoading] = useState(false);
-
- const [showLoginModal, setShowLoginModal] = useState(false);
- const [loginProvider, setLoginProvider] = useState('');
- const [selectedProject, setSelectedProject] = useState(null);
-
- const [claudeAuthStatus, setClaudeAuthStatus] = useState({
- authenticated: false,
- email: null,
- loading: true,
- error: null
- });
- const [cursorAuthStatus, setCursorAuthStatus] = useState({
- authenticated: false,
- email: null,
- loading: true,
- error: null
- });
- const [codexAuthStatus, setCodexAuthStatus] = useState({
- authenticated: false,
- email: null,
- loading: true,
- error: null
- });
-
- // Common tool patterns for Claude
- const commonTools = [
- 'Bash(git log:*)',
- 'Bash(git diff:*)',
- 'Bash(git status:*)',
- 'Write',
- 'Read',
- 'Edit',
- 'Glob',
- 'Grep',
- 'MultiEdit',
- 'Task',
- 'TodoWrite',
- 'TodoRead',
- 'WebFetch',
- 'WebSearch'
- ];
-
- // Common shell commands for Cursor
- const commonCursorCommands = [
- 'Shell(ls)',
- 'Shell(mkdir)',
- 'Shell(cd)',
- 'Shell(cat)',
- 'Shell(echo)',
- 'Shell(git status)',
- 'Shell(git diff)',
- 'Shell(git log)',
- 'Shell(npm install)',
- 'Shell(npm run)',
- 'Shell(python)',
- 'Shell(node)'
- ];
-
- // Fetch Cursor MCP servers
- const fetchCursorMcpServers = async () => {
- try {
- const response = await authenticatedFetch('/api/cursor/mcp');
-
- if (response.ok) {
- const data = await response.json();
- setCursorMcpServers(data.servers || []);
- } else {
- console.error('Failed to fetch Cursor MCP servers');
- }
- } catch (error) {
- console.error('Error fetching Cursor MCP servers:', error);
- }
- };
-
- const fetchCodexMcpServers = async () => {
- try {
- const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
-
- if (configResponse.ok) {
- const configData = await configResponse.json();
- if (configData.success && configData.servers) {
- setCodexMcpServers(configData.servers);
- return;
- }
- }
-
- const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
-
- if (cliResponse.ok) {
- const cliData = await cliResponse.json();
- if (cliData.success && cliData.servers) {
- const servers = cliData.servers.map(server => ({
- id: server.name,
- name: server.name,
- type: server.type || 'stdio',
- scope: 'user',
- config: {
- command: server.command || '',
- args: server.args || [],
- env: server.env || {}
- }
- }));
- setCodexMcpServers(servers);
- }
- }
- } catch (error) {
- console.error('Error fetching Codex MCP servers:', error);
- }
- };
-
- // MCP API functions
- const fetchMcpServers = async () => {
- try {
- // Try to read directly from config files for complete details
- const configResponse = await authenticatedFetch('/api/mcp/config/read');
-
- if (configResponse.ok) {
- const configData = await configResponse.json();
- if (configData.success && configData.servers) {
- setMcpServers(configData.servers);
- return;
- }
- }
-
- // Fallback to Claude CLI
- const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
-
- if (cliResponse.ok) {
- const cliData = await cliResponse.json();
- if (cliData.success && cliData.servers) {
- // Convert CLI format to our format
- const servers = cliData.servers.map(server => ({
- id: server.name,
- name: server.name,
- type: server.type,
- scope: 'user',
- config: {
- command: server.command || '',
- args: server.args || [],
- env: server.env || {},
- url: server.url || '',
- headers: server.headers || {},
- timeout: 30000
- },
- created: new Date().toISOString(),
- updated: new Date().toISOString()
- }));
- setMcpServers(servers);
- return;
- }
- }
-
- // Final fallback to direct config reading
- const response = await authenticatedFetch('/api/mcp/servers?scope=user');
-
- if (response.ok) {
- const data = await response.json();
- setMcpServers(data.servers || []);
- } else {
- console.error('Failed to fetch MCP servers');
- }
- } catch (error) {
- console.error('Error fetching MCP servers:', error);
- }
- };
-
- const saveMcpServer = async (serverData) => {
- try {
- if (editingMcpServer) {
- // For editing, remove old server and add new one
- await deleteMcpServer(editingMcpServer.id, 'user');
- }
-
- // Use Claude CLI to add the server
- const response = await authenticatedFetch('/api/mcp/cli/add', {
- method: 'POST',
- body: JSON.stringify({
- name: serverData.name,
- type: serverData.type,
- scope: serverData.scope,
- projectPath: serverData.projectPath,
- command: serverData.config?.command,
- args: serverData.config?.args || [],
- url: serverData.config?.url,
- headers: serverData.config?.headers || {},
- env: serverData.config?.env || {}
- })
- });
-
- if (response.ok) {
- const result = await response.json();
- if (result.success) {
- await fetchMcpServers(); // Refresh the list
- return true;
- } else {
- throw new Error(result.error || 'Failed to save server via Claude CLI');
- }
- } else {
- const error = await response.json();
- throw new Error(error.error || 'Failed to save server');
- }
- } catch (error) {
- console.error('Error saving MCP server:', error);
- throw error;
- }
- };
-
- const deleteMcpServer = async (serverId, scope = 'user') => {
- try {
- // Use Claude CLI to remove the server with proper scope
- const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
- method: 'DELETE'
- });
-
- if (response.ok) {
- const result = await response.json();
- if (result.success) {
- await fetchMcpServers(); // Refresh the list
- return true;
- } else {
- throw new Error(result.error || 'Failed to delete server via Claude CLI');
- }
- } else {
- const error = await response.json();
- throw new Error(error.error || 'Failed to delete server');
- }
- } catch (error) {
- console.error('Error deleting MCP server:', error);
- throw error;
- }
- };
-
- const testMcpServer = async (serverId, scope = 'user') => {
- try {
- const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
- method: 'POST'
- });
-
- if (response.ok) {
- const data = await response.json();
- return data.testResult;
- } else {
- const error = await response.json();
- throw new Error(error.error || 'Failed to test server');
- }
- } catch (error) {
- console.error('Error testing MCP server:', error);
- throw error;
- }
- };
-
-
- const discoverMcpTools = async (serverId, scope = 'user') => {
- try {
- const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
- method: 'POST'
- });
-
- if (response.ok) {
- const data = await response.json();
- return data.toolsResult;
- } else {
- const error = await response.json();
- throw new Error(error.error || 'Failed to discover tools');
- }
- } catch (error) {
- console.error('Error discovering MCP tools:', error);
- throw error;
- }
- };
-
- const saveCodexMcpServer = async (serverData) => {
- try {
- if (editingCodexMcpServer) {
- await deleteCodexMcpServer(editingCodexMcpServer.id);
- }
-
- const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
- method: 'POST',
- body: JSON.stringify({
- name: serverData.name,
- command: serverData.config?.command,
- args: serverData.config?.args || [],
- env: serverData.config?.env || {}
- })
- });
-
- if (response.ok) {
- const result = await response.json();
- if (result.success) {
- await fetchCodexMcpServers();
- return true;
- } else {
- throw new Error(result.error || 'Failed to save Codex MCP server');
- }
- } else {
- const error = await response.json();
- throw new Error(error.error || 'Failed to save server');
- }
- } catch (error) {
- console.error('Error saving Codex MCP server:', error);
- throw error;
- }
- };
-
- const deleteCodexMcpServer = async (serverId) => {
- try {
- const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
- method: 'DELETE'
- });
-
- if (response.ok) {
- const result = await response.json();
- if (result.success) {
- await fetchCodexMcpServers();
- return true;
- } else {
- throw new Error(result.error || 'Failed to delete Codex MCP server');
- }
- } else {
- const error = await response.json();
- throw new Error(error.error || 'Failed to delete server');
- }
- } catch (error) {
- console.error('Error deleting Codex MCP server:', error);
- throw error;
- }
- };
-
- const resetCodexMcpForm = () => {
- setCodexMcpFormData({
- name: '',
- type: 'stdio',
- config: {
- command: '',
- args: [],
- env: {}
- }
- });
- setEditingCodexMcpServer(null);
- setShowCodexMcpForm(false);
- };
-
- const openCodexMcpForm = (server = null) => {
- if (server) {
- setEditingCodexMcpServer(server);
- setCodexMcpFormData({
- name: server.name,
- type: server.type || 'stdio',
- config: {
- command: server.config?.command || '',
- args: server.config?.args || [],
- env: server.config?.env || {}
- }
- });
- } else {
- resetCodexMcpForm();
- }
- setShowCodexMcpForm(true);
- };
-
- const handleCodexMcpSubmit = async (e) => {
- e.preventDefault();
- setCodexMcpLoading(true);
-
- try {
- if (editingCodexMcpServer) {
- // Delete old server first, then add new one
- await deleteCodexMcpServer(editingCodexMcpServer.name);
- }
- await saveCodexMcpServer(codexMcpFormData);
- resetCodexMcpForm();
- setSaveStatus('success');
- } catch (error) {
- alert(`Error: ${error.message}`);
- setSaveStatus('error');
- } finally {
- setCodexMcpLoading(false);
- }
- };
-
- const handleCodexMcpDelete = async (serverName) => {
- if (confirm('Are you sure you want to delete this MCP server?')) {
- try {
- await deleteCodexMcpServer(serverName);
- setSaveStatus('success');
- } catch (error) {
- alert(`Error: ${error.message}`);
- setSaveStatus('error');
- }
- }
- };
-
- useEffect(() => {
- if (isOpen) {
- loadSettings();
- checkClaudeAuthStatus();
- checkCursorAuthStatus();
- checkCodexAuthStatus();
- setActiveTab(initialTab);
- }
- }, [isOpen, initialTab]);
-
- // Persist code editor settings to localStorage
- useEffect(() => {
- localStorage.setItem('codeEditorTheme', codeEditorTheme);
- window.dispatchEvent(new Event('codeEditorSettingsChanged'));
- }, [codeEditorTheme]);
-
- useEffect(() => {
- localStorage.setItem('codeEditorWordWrap', codeEditorWordWrap.toString());
- window.dispatchEvent(new Event('codeEditorSettingsChanged'));
- }, [codeEditorWordWrap]);
-
- useEffect(() => {
- localStorage.setItem('codeEditorShowMinimap', codeEditorShowMinimap.toString());
- window.dispatchEvent(new Event('codeEditorSettingsChanged'));
- }, [codeEditorShowMinimap]);
-
- useEffect(() => {
- localStorage.setItem('codeEditorLineNumbers', codeEditorLineNumbers.toString());
- window.dispatchEvent(new Event('codeEditorSettingsChanged'));
- }, [codeEditorLineNumbers]);
-
- useEffect(() => {
- localStorage.setItem('codeEditorFontSize', codeEditorFontSize);
- window.dispatchEvent(new Event('codeEditorSettingsChanged'));
- }, [codeEditorFontSize]);
-
- const loadSettings = async () => {
- try {
-
- // Load Claude settings from localStorage
- const savedSettings = localStorage.getItem('claude-settings');
-
- if (savedSettings) {
- const settings = JSON.parse(savedSettings);
- setAllowedTools(settings.allowedTools || []);
- setDisallowedTools(settings.disallowedTools || []);
- setSkipPermissions(settings.skipPermissions || false);
- setProjectSortOrder(settings.projectSortOrder || 'name');
- } else {
- // Set defaults
- setAllowedTools([]);
- setDisallowedTools([]);
- setSkipPermissions(false);
- setProjectSortOrder('name');
- }
-
- // Load Cursor settings from localStorage
- const savedCursorSettings = localStorage.getItem('cursor-tools-settings');
-
- if (savedCursorSettings) {
- const cursorSettings = JSON.parse(savedCursorSettings);
- setCursorAllowedCommands(cursorSettings.allowedCommands || []);
- setCursorDisallowedCommands(cursorSettings.disallowedCommands || []);
- setCursorSkipPermissions(cursorSettings.skipPermissions || false);
- } else {
- // Set Cursor defaults
- setCursorAllowedCommands([]);
- setCursorDisallowedCommands([]);
- setCursorSkipPermissions(false);
- }
-
- // Load Codex settings from localStorage
- const savedCodexSettings = localStorage.getItem('codex-settings');
-
- if (savedCodexSettings) {
- const codexSettings = JSON.parse(savedCodexSettings);
- setCodexPermissionMode(codexSettings.permissionMode || 'default');
- } else {
- setCodexPermissionMode('default');
- }
-
- // Load MCP servers from API
- await fetchMcpServers();
-
- // Load Cursor MCP servers
- await fetchCursorMcpServers();
-
- // Load Codex MCP servers
- await fetchCodexMcpServers();
- } catch (error) {
- console.error('Error loading tool settings:', error);
- setAllowedTools([]);
- setDisallowedTools([]);
- setSkipPermissions(false);
- setProjectSortOrder('name');
- }
- };
-
- const checkClaudeAuthStatus = async () => {
- try {
- const response = await authenticatedFetch('/api/cli/claude/status');
-
- if (response.ok) {
- const data = await response.json();
- setClaudeAuthStatus({
- authenticated: data.authenticated,
- email: data.email,
- loading: false,
- error: data.error || null
- });
- } else {
- setClaudeAuthStatus({
- authenticated: false,
- email: null,
- loading: false,
- error: 'Failed to check authentication status'
- });
- }
- } catch (error) {
- console.error('Error checking Claude auth status:', error);
- setClaudeAuthStatus({
- authenticated: false,
- email: null,
- loading: false,
- error: error.message
- });
- }
- };
-
- const checkCursorAuthStatus = async () => {
- try {
- const response = await authenticatedFetch('/api/cli/cursor/status');
-
- if (response.ok) {
- const data = await response.json();
- setCursorAuthStatus({
- authenticated: data.authenticated,
- email: data.email,
- loading: false,
- error: data.error || null
- });
- } else {
- setCursorAuthStatus({
- authenticated: false,
- email: null,
- loading: false,
- error: 'Failed to check authentication status'
- });
- }
- } catch (error) {
- console.error('Error checking Cursor auth status:', error);
- setCursorAuthStatus({
- authenticated: false,
- email: null,
- loading: false,
- error: error.message
- });
- }
- };
-
- const checkCodexAuthStatus = async () => {
- try {
- const response = await authenticatedFetch('/api/cli/codex/status');
-
- if (response.ok) {
- const data = await response.json();
- setCodexAuthStatus({
- authenticated: data.authenticated,
- email: data.email,
- loading: false,
- error: data.error || null
- });
- } else {
- setCodexAuthStatus({
- authenticated: false,
- email: null,
- loading: false,
- error: 'Failed to check authentication status'
- });
- }
- } catch (error) {
- console.error('Error checking Codex auth status:', error);
- setCodexAuthStatus({
- authenticated: false,
- email: null,
- loading: false,
- error: error.message
- });
- }
- };
-
- const handleClaudeLogin = () => {
- setLoginProvider('claude');
- setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
- setShowLoginModal(true);
- };
-
- const handleCursorLogin = () => {
- setLoginProvider('cursor');
- setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
- setShowLoginModal(true);
- };
-
- const handleCodexLogin = () => {
- setLoginProvider('codex');
- setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
- setShowLoginModal(true);
- };
-
- const handleLoginComplete = (exitCode) => {
- if (exitCode === 0) {
- setSaveStatus('success');
-
- if (loginProvider === 'claude') {
- checkClaudeAuthStatus();
- } else if (loginProvider === 'cursor') {
- checkCursorAuthStatus();
- } else if (loginProvider === 'codex') {
- checkCodexAuthStatus();
- }
- }
- };
-
- const saveSettings = () => {
- setIsSaving(true);
- setSaveStatus(null);
-
- try {
- // Save Claude settings
- const claudeSettings = {
- allowedTools,
- disallowedTools,
- skipPermissions,
- projectSortOrder,
- lastUpdated: new Date().toISOString()
- };
-
- // Save Cursor settings
- const cursorSettings = {
- allowedCommands: cursorAllowedCommands,
- disallowedCommands: cursorDisallowedCommands,
- skipPermissions: cursorSkipPermissions,
- lastUpdated: new Date().toISOString()
- };
-
- // Save Codex settings
- const codexSettings = {
- permissionMode: codexPermissionMode,
- lastUpdated: new Date().toISOString()
- };
-
- // Save to localStorage
- localStorage.setItem('claude-settings', JSON.stringify(claudeSettings));
- localStorage.setItem('cursor-tools-settings', JSON.stringify(cursorSettings));
- localStorage.setItem('codex-settings', JSON.stringify(codexSettings));
-
- setSaveStatus('success');
-
- setTimeout(() => {
- onClose();
- }, 1000);
- } catch (error) {
- console.error('Error saving tool settings:', error);
- setSaveStatus('error');
- } finally {
- setIsSaving(false);
- }
- };
-
- const addAllowedTool = (tool) => {
- if (tool && !allowedTools.includes(tool)) {
- setAllowedTools([...allowedTools, tool]);
- setNewAllowedTool('');
- }
- };
-
- const removeAllowedTool = (tool) => {
- setAllowedTools(allowedTools.filter(t => t !== tool));
- };
-
- const addDisallowedTool = (tool) => {
- if (tool && !disallowedTools.includes(tool)) {
- setDisallowedTools([...disallowedTools, tool]);
- setNewDisallowedTool('');
- }
- };
-
- const removeDisallowedTool = (tool) => {
- setDisallowedTools(disallowedTools.filter(t => t !== tool));
- };
-
- // MCP form handling functions
- const resetMcpForm = () => {
- setMcpFormData({
- name: '',
- type: 'stdio',
- scope: 'user', // Default to user scope
- projectPath: '',
- config: {
- command: '',
- args: [],
- env: {},
- url: '',
- headers: {},
- timeout: 30000
- },
- jsonInput: '',
- importMode: 'form'
- });
- setEditingMcpServer(null);
- setShowMcpForm(false);
- setJsonValidationError('');
- };
-
- const openMcpForm = (server = null) => {
- if (server) {
- setEditingMcpServer(server);
- setMcpFormData({
- name: server.name,
- type: server.type,
- scope: server.scope,
- projectPath: server.projectPath || '',
- config: { ...server.config },
- raw: server.raw, // Store raw config for display
- importMode: 'form', // Always use form mode when editing
- jsonInput: ''
- });
- } else {
- resetMcpForm();
- }
- setShowMcpForm(true);
- };
-
- const handleMcpSubmit = async (e) => {
- e.preventDefault();
-
- setMcpLoading(true);
-
- try {
- if (mcpFormData.importMode === 'json') {
- // Use JSON import endpoint
- const response = await authenticatedFetch('/api/mcp/cli/add-json', {
- method: 'POST',
- body: JSON.stringify({
- name: mcpFormData.name,
- jsonConfig: mcpFormData.jsonInput,
- scope: mcpFormData.scope,
- projectPath: mcpFormData.projectPath
- })
- });
-
- if (response.ok) {
- const result = await response.json();
- if (result.success) {
- await fetchMcpServers(); // Refresh the list
- resetMcpForm();
- setSaveStatus('success');
- } else {
- throw new Error(result.error || 'Failed to add server via JSON');
- }
- } else {
- const error = await response.json();
- throw new Error(error.error || 'Failed to add server');
- }
- } else {
- // Use regular form-based save
- await saveMcpServer(mcpFormData);
- resetMcpForm();
- setSaveStatus('success');
- }
- } catch (error) {
- alert(`Error: ${error.message}`);
- setSaveStatus('error');
- } finally {
- setMcpLoading(false);
- }
- };
-
- const handleMcpDelete = async (serverId, scope) => {
- if (confirm('Are you sure you want to delete this MCP server?')) {
- try {
- await deleteMcpServer(serverId, scope);
- setSaveStatus('success');
- } catch (error) {
- alert(`Error: ${error.message}`);
- setSaveStatus('error');
- }
- }
- };
-
- const handleMcpTest = async (serverId, scope) => {
- try {
- setMcpTestResults({ ...mcpTestResults, [serverId]: { loading: true } });
- const result = await testMcpServer(serverId, scope);
- setMcpTestResults({ ...mcpTestResults, [serverId]: result });
- } catch (error) {
- setMcpTestResults({
- ...mcpTestResults,
- [serverId]: {
- success: false,
- message: error.message,
- details: []
- }
- });
- }
- };
-
- const handleMcpToolsDiscovery = async (serverId, scope) => {
- try {
- setMcpToolsLoading({ ...mcpToolsLoading, [serverId]: true });
- const result = await discoverMcpTools(serverId, scope);
- setMcpServerTools({ ...mcpServerTools, [serverId]: result });
- } catch (error) {
- setMcpServerTools({
- ...mcpServerTools,
- [serverId]: {
- success: false,
- tools: [],
- resources: [],
- prompts: []
- }
- });
- } finally {
- setMcpToolsLoading({ ...mcpToolsLoading, [serverId]: false });
- }
- };
-
- const updateMcpConfig = (key, value) => {
- setMcpFormData(prev => ({
- ...prev,
- config: {
- ...prev.config,
- [key]: value
- }
- }));
- };
-
-
- const getTransportIcon = (type) => {
- switch (type) {
- case 'stdio': return
;
- case 'sse': return
;
- case 'http': return
;
- default: return
;
- }
- };
-
- if (!isOpen) return null;
-
- return (
-
-
-
-
-
-
- {t('title')}
-
-
-
-
-
-
-
-
- {/* Tab Navigation */}
-
-
- setActiveTab('agents')}
- className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
- activeTab === 'agents'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
- {t('mainTabs.agents')}
-
- setActiveTab('appearance')}
- className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
- activeTab === 'appearance'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
- {t('mainTabs.appearance')}
-
- setActiveTab('git')}
- className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
- activeTab === 'git'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
-
- {t('mainTabs.git')}
-
- setActiveTab('api')}
- className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
- activeTab === 'api'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
-
- {t('mainTabs.apiTokens')}
-
- setActiveTab('tasks')}
- className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
- activeTab === 'tasks'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
- {t('mainTabs.tasks')}
-
-
-
-
-
-
- {/* Appearance Tab */}
- {activeTab === 'appearance' && (
-
- {activeTab === 'appearance' && (
-
- {/* Theme Settings */}
-
-
-
-
-
- {t('appearanceSettings.darkMode.label')}
-
-
- {t('appearanceSettings.darkMode.description')}
-
-
-
- Toggle dark mode
-
- {isDarkMode ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- {/* Language Selector */}
-
-
-
-
- {/* Project Sorting */}
-
-
-
-
-
- {t('appearanceSettings.projectSorting.label')}
-
-
- {t('appearanceSettings.projectSorting.description')}
-
-
-
setProjectSortOrder(e.target.value)}
- className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-32"
- >
- {t('appearanceSettings.projectSorting.alphabetical')}
- {t('appearanceSettings.projectSorting.recentActivity')}
-
-
-
-
-
- {/* Code Editor Settings */}
-
-
{t('appearanceSettings.codeEditor.title')}
-
- {/* Editor Theme */}
-
-
-
-
- {t('appearanceSettings.codeEditor.theme.label')}
-
-
- {t('appearanceSettings.codeEditor.theme.description')}
-
-
-
setCodeEditorTheme(codeEditorTheme === 'dark' ? 'light' : 'dark')}
- className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
- role="switch"
- aria-checked={codeEditorTheme === 'dark'}
- aria-label="Toggle editor theme"
- >
- Toggle editor theme
-
- {codeEditorTheme === 'dark' ? (
-
- ) : (
-
- )}
-
-
-
-
-
- {/* Word Wrap */}
-
-
-
-
- {t('appearanceSettings.codeEditor.wordWrap.label')}
-
-
- {t('appearanceSettings.codeEditor.wordWrap.description')}
-
-
-
setCodeEditorWordWrap(!codeEditorWordWrap)}
- className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
- role="switch"
- aria-checked={codeEditorWordWrap}
- aria-label="Toggle word wrap"
- >
- Toggle word wrap
-
-
-
-
-
- {/* Show Minimap */}
-
-
-
-
- {t('appearanceSettings.codeEditor.showMinimap.label')}
-
-
- {t('appearanceSettings.codeEditor.showMinimap.description')}
-
-
-
setCodeEditorShowMinimap(!codeEditorShowMinimap)}
- className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
- role="switch"
- aria-checked={codeEditorShowMinimap}
- aria-label="Toggle minimap"
- >
- Toggle minimap
-
-
-
-
-
- {/* Show Line Numbers */}
-
-
-
-
- {t('appearanceSettings.codeEditor.lineNumbers.label')}
-
-
- {t('appearanceSettings.codeEditor.lineNumbers.description')}
-
-
-
setCodeEditorLineNumbers(!codeEditorLineNumbers)}
- className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
- role="switch"
- aria-checked={codeEditorLineNumbers}
- aria-label="Toggle line numbers"
- >
- Toggle line numbers
-
-
-
-
-
- {/* Font Size */}
-
-
-
-
- {t('appearanceSettings.codeEditor.fontSize.label')}
-
-
- {t('appearanceSettings.codeEditor.fontSize.description')}
-
-
-
setCodeEditorFontSize(e.target.value)}
- className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-24"
- >
- 10px
- 11px
- 12px
- 13px
- 14px
- 15px
- 16px
- 18px
- 20px
-
-
-
-
-
-)}
-
-
- )}
-
- {/* Git Tab */}
- {activeTab === 'git' &&
}
-
- {/* Agents Tab */}
- {activeTab === 'agents' && (
-
- {/* Mobile: Horizontal Agent Tabs */}
-
-
-
setSelectedAgent('claude')}
- isMobile={true}
- />
- setSelectedAgent('cursor')}
- isMobile={true}
- />
- setSelectedAgent('codex')}
- isMobile={true}
- />
-
-
-
- {/* Desktop: Sidebar - Agent List */}
-
-
-
setSelectedAgent('claude')}
- />
- setSelectedAgent('cursor')}
- />
- setSelectedAgent('codex')}
- />
-
-
-
- {/* Main Panel */}
-
- {/* Category Tabs */}
-
-
- setSelectedCategory('account')}
- className={`px-3 md:px-4 py-2 md:py-3 text-xs md:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
- selectedCategory === 'account'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
- {t('tabs.account')}
-
- setSelectedCategory('permissions')}
- className={`px-3 md:px-4 py-2 md:py-3 text-xs md:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
- selectedCategory === 'permissions'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
- {t('tabs.permissions')}
-
- setSelectedCategory('mcp')}
- className={`px-3 md:px-4 py-2 md:py-3 text-xs md:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
- selectedCategory === 'mcp'
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-muted-foreground hover:text-foreground'
- }`}
- >
- {t('tabs.mcpServers')}
-
-
-
-
- {/* Category Content */}
-
- {/* Account Category */}
- {selectedCategory === 'account' && (
-
- )}
-
- {/* Permissions Category */}
- {selectedCategory === 'permissions' && selectedAgent === 'claude' && (
-
- )}
-
- {selectedCategory === 'permissions' && selectedAgent === 'cursor' && (
-
- )}
-
- {selectedCategory === 'permissions' && selectedAgent === 'codex' && (
-
- )}
-
- {/* MCP Servers Category */}
- {selectedCategory === 'mcp' && selectedAgent === 'claude' && (
-
openMcpForm()}
- onEdit={(server) => openMcpForm(server)}
- onDelete={(serverId, scope) => handleMcpDelete(serverId, scope)}
- onTest={(serverId, scope) => handleMcpTest(serverId, scope)}
- onDiscoverTools={(serverId, scope) => handleMcpToolsDiscovery(serverId, scope)}
- testResults={mcpTestResults}
- serverTools={mcpServerTools}
- toolsLoading={mcpToolsLoading}
- />
- )}
-
- {selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
- {/* TODO: Add cursor MCP form */}}
- onEdit={(server) => {/* TODO: Edit cursor MCP form */}}
- onDelete={(serverId) => {/* TODO: Delete cursor MCP */}}
- />
- )}
-
- {selectedCategory === 'mcp' && selectedAgent === 'codex' && (
- openCodexMcpForm()}
- onEdit={(server) => openCodexMcpForm(server)}
- onDelete={(serverId) => handleCodexMcpDelete(serverId)}
- />
- )}
-
-
-
- )}
-
- {/* MCP Server Form Modal */}
- {showMcpForm && (
-
-
-
-
- {editingMcpServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Codex MCP Server Form Modal */}
- {showCodexMcpForm && (
-
-
-
-
- {editingCodexMcpServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
-
-
-
-
-
-
-
-
-
- {t('mcpForm.fields.serverName')} *
-
- setCodexMcpFormData(prev => ({...prev, name: e.target.value}))}
- placeholder={t('mcpForm.placeholders.serverName')}
- required
- />
-
-
-
-
- {t('mcpForm.fields.command')} *
-
- setCodexMcpFormData(prev => ({
- ...prev,
- config: { ...prev.config, command: e.target.value }
- }))}
- placeholder="npx @my-org/mcp-server"
- required
- />
-
-
-
-
- {t('mcpForm.fields.arguments')}
-
- setCodexMcpFormData(prev => ({
- ...prev,
- config: { ...prev.config, args: e.target.value.split('\n').filter(a => a.trim()) }
- }))}
- placeholder="--port
3000"
- rows={3}
- className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
- />
-
-
-
-
- {t('mcpForm.fields.envVars')}
-
- `${k}=${v}`).join('\n')}
- onChange={(e) => {
- const env = {};
- e.target.value.split('\n').forEach(line => {
- const [key, ...valueParts] = line.split('=');
- if (key && valueParts.length > 0) {
- env[key.trim()] = valueParts.join('=').trim();
- }
- });
- setCodexMcpFormData(prev => ({
- ...prev,
- config: { ...prev.config, env }
- }));
- }}
- placeholder="API_KEY=xxx
DEBUG=true"
- rows={3}
- className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
- />
-
-
-
-
- {t('mcpForm.actions.cancel')}
-
-
- {codexMcpLoading ? t('mcpForm.actions.saving') : (editingCodexMcpServer ? t('mcpForm.actions.updateServer') : t('mcpForm.actions.addServer'))}
-
-
-
-
-
- )}
-
- {/* Tasks Tab */}
- {activeTab === 'tasks' && (
-
-
-
- )}
-
- {/* API & Tokens Tab */}
- {activeTab === 'api' && (
-
-
-
- )}
-
-
-
-
-
- {saveStatus === 'success' && (
-
-
-
-
- {t('saveStatus.success')}
-
- )}
- {saveStatus === 'error' && (
-
-
-
-
- {t('saveStatus.error')}
-
- )}
-
-
-
- {t('footerActions.cancel')}
-
-
- {isSaving ? (
-
-
- {t('saveStatus.saving')}
-
- ) : (
- t('footerActions.save')
- )}
-
-
-
-
-
- {/* Login Modal */}
-
setShowLoginModal(false)}
- provider={loginProvider}
- project={selectedProject}
- onComplete={handleLoginComplete}
- isAuthenticated={
- loginProvider === 'claude' ? claudeAuthStatus.authenticated :
- loginProvider === 'cursor' ? cursorAuthStatus.authenticated :
- loginProvider === 'codex' ? codexAuthStatus.authenticated :
- false
- }
- />
-
- );
-}
-
-export default Settings;
diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx
deleted file mode 100644
index 44115b9..0000000
--- a/src/components/Shell.jsx
+++ /dev/null
@@ -1,692 +0,0 @@
-import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
-import { Terminal } from '@xterm/xterm';
-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';
-import { IS_PLATFORM } from '../constants/config';
-
-const xtermStyles = `
- .xterm .xterm-screen {
- outline: none !important;
- }
- .xterm:focus .xterm-screen {
- outline: none !important;
- }
- .xterm-screen:focus {
- outline: none !important;
- }
-`;
-
-if (typeof document !== 'undefined') {
- const styleSheet = document.createElement('style');
- styleSheet.type = 'text/css';
- styleSheet.innerText = xtermStyles;
- document.head.appendChild(styleSheet);
-}
-
-function fallbackCopyToClipboard(text) {
- if (!text || typeof document === 'undefined') return false;
-
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.setAttribute('readonly', '');
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- textarea.style.pointerEvents = 'none';
- document.body.appendChild(textarea);
- textarea.focus();
- textarea.select();
-
- let copied = false;
- try {
- copied = document.execCommand('copy');
- } catch {
- copied = false;
- } finally {
- document.body.removeChild(textarea);
- }
-
- return copied;
-}
-
-const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
-
-function isCodexLoginCommand(command) {
- return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
-}
-
-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);
- const ws = useRef(null);
- const [isConnected, setIsConnected] = useState(false);
- const [isInitialized, setIsInitialized] = useState(false);
- const [isRestarting, setIsRestarting] = useState(false);
- const [lastSessionId, setLastSessionId] = useState(null);
- const [isConnecting, setIsConnecting] = useState(false);
- const [authUrl, setAuthUrl] = useState('');
- const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
- const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
-
- const selectedProjectRef = useRef(selectedProject);
- const selectedSessionRef = useRef(selectedSession);
- const initialCommandRef = useRef(initialCommand);
- const isPlainShellRef = useRef(isPlainShell);
- const onProcessCompleteRef = useRef(onProcessComplete);
- const authUrlRef = useRef('');
-
- useEffect(() => {
- selectedProjectRef.current = selectedProject;
- selectedSessionRef.current = selectedSession;
- initialCommandRef.current = initialCommand;
- isPlainShellRef.current = isPlainShell;
- onProcessCompleteRef.current = onProcessComplete;
- });
-
- const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
- if (!url) return false;
-
- const popup = window.open(url, '_blank', 'noopener,noreferrer');
- if (popup) {
- try {
- popup.opener = null;
- } catch {
- // Ignore cross-origin restrictions when trying to null opener
- }
- return true;
- }
-
- return false;
- }, []);
-
- const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
- if (!url) return false;
-
- let copied = false;
- try {
- if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(url);
- copied = true;
- }
- } catch {
- copied = false;
- }
-
- if (!copied) {
- copied = fallbackCopyToClipboard(url);
- }
-
- return copied;
- }, []);
-
- const connectWebSocket = useCallback(async () => {
- if (isConnecting || isConnected) return;
-
- try {
- let wsUrl;
-
- if (IS_PLATFORM) {
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- wsUrl = `${protocol}//${window.location.host}/shell`;
- } else {
- const token = localStorage.getItem('auth-token');
- if (!token) {
- console.error('No authentication token found for Shell WebSocket connection');
- return;
- }
-
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
- }
-
- ws.current = new WebSocket(wsUrl);
-
- ws.current.onopen = () => {
- setIsConnected(true);
- setIsConnecting(false);
- authUrlRef.current = '';
- setAuthUrl('');
- setAuthUrlCopyStatus('idle');
- setIsAuthPanelHidden(false);
-
- setTimeout(() => {
- if (fitAddon.current && terminal.current) {
- fitAddon.current.fit();
-
- ws.current.send(JSON.stringify({
- type: 'init',
- projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
- sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
- hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
- provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-provider') || 'claude'),
- cols: terminal.current.cols,
- rows: terminal.current.rows,
- initialCommand: initialCommandRef.current,
- isPlainShell: isPlainShellRef.current
- }));
- }
- }, 100);
- };
-
- ws.current.onmessage = (event) => {
- try {
- const data = JSON.parse(event.data);
-
- if (data.type === 'output') {
- let output = data.data;
-
- if (isPlainShellRef.current && onProcessCompleteRef.current) {
- const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
- if (cleanOutput.includes('Process exited with code 0')) {
- onProcessCompleteRef.current(0);
- } else if (cleanOutput.match(/Process exited with code (\d+)/)) {
- const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
- if (exitCode !== 0) {
- onProcessCompleteRef.current(exitCode);
- }
- }
- }
-
- if (terminal.current) {
- terminal.current.write(output);
- }
- } else if (data.type === 'auth_url' && data.url) {
- authUrlRef.current = data.url;
- setAuthUrl(data.url);
- setAuthUrlCopyStatus('idle');
- setIsAuthPanelHidden(false);
- } else if (data.type === 'url_open') {
- if (data.url) {
- authUrlRef.current = data.url;
- setAuthUrl(data.url);
- setAuthUrlCopyStatus('idle');
- setIsAuthPanelHidden(false);
- }
- }
- } catch (error) {
- console.error('[Shell] Error handling WebSocket message:', error, event.data);
- }
- };
-
- ws.current.onclose = (event) => {
- setIsConnected(false);
- setIsConnecting(false);
- setAuthUrlCopyStatus('idle');
- setIsAuthPanelHidden(false);
-
- if (terminal.current) {
- terminal.current.clear();
- terminal.current.write('\x1b[2J\x1b[H');
- }
- };
-
- ws.current.onerror = (error) => {
- setIsConnected(false);
- setIsConnecting(false);
- };
- } catch (error) {
- setIsConnected(false);
- setIsConnecting(false);
- }
- }, [isConnecting, isConnected, openAuthUrlInBrowser]);
-
- const connectToShell = useCallback(() => {
- if (!isInitialized || isConnected || isConnecting) return;
- setIsConnecting(true);
- connectWebSocket();
- }, [isInitialized, isConnected, isConnecting, connectWebSocket]);
-
- const disconnectFromShell = useCallback(() => {
- if (ws.current) {
- ws.current.close();
- ws.current = null;
- }
-
- if (terminal.current) {
- terminal.current.clear();
- terminal.current.write('\x1b[2J\x1b[H');
- }
-
- setIsConnected(false);
- setIsConnecting(false);
- authUrlRef.current = '';
- setAuthUrl('');
- setAuthUrlCopyStatus('idle');
- setIsAuthPanelHidden(false);
- }, []);
-
- const sessionDisplayName = useMemo(() => {
- if (!selectedSession) return null;
- return selectedSession.__provider === 'cursor'
- ? (selectedSession.name || 'Untitled Session')
- : (selectedSession.summary || 'New Session');
- }, [selectedSession]);
-
- const sessionDisplayNameShort = useMemo(() => {
- if (!sessionDisplayName) return null;
- return sessionDisplayName.slice(0, 30);
- }, [sessionDisplayName]);
-
- const sessionDisplayNameLong = useMemo(() => {
- if (!sessionDisplayName) return null;
- return sessionDisplayName.slice(0, 50);
- }, [sessionDisplayName]);
-
- const restartShell = () => {
- setIsRestarting(true);
-
- if (ws.current) {
- ws.current.close();
- ws.current = null;
- }
-
- if (terminal.current) {
- terminal.current.dispose();
- terminal.current = null;
- fitAddon.current = null;
- }
-
- setIsConnected(false);
- setIsInitialized(false);
- authUrlRef.current = '';
- setAuthUrl('');
- setAuthUrlCopyStatus('idle');
- setIsAuthPanelHidden(false);
-
- setTimeout(() => {
- setIsRestarting(false);
- }, 200);
- };
-
- useEffect(() => {
- const currentSessionId = selectedSession?.id || null;
-
- if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
- disconnectFromShell();
- }
-
- setLastSessionId(currentSessionId);
- }, [selectedSession?.id, isInitialized, disconnectFromShell]);
-
- useEffect(() => {
- if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
- return;
- }
-
-
- terminal.current = new Terminal({
- cursorBlink: true,
- fontSize: 14,
- fontFamily: 'Menlo, Monaco, "Courier New", monospace',
- allowProposedApi: true,
- allowTransparency: false,
- convertEol: true,
- scrollback: 10000,
- tabStopWidth: 4,
- windowsMode: false,
- macOptionIsMeta: true,
- macOptionClickForcesSelection: true,
- theme: {
- background: '#1e1e1e',
- foreground: '#d4d4d4',
- cursor: '#ffffff',
- cursorAccent: '#1e1e1e',
- selection: '#264f78',
- selectionForeground: '#ffffff',
- black: '#000000',
- red: '#cd3131',
- green: '#0dbc79',
- yellow: '#e5e510',
- blue: '#2472c8',
- magenta: '#bc3fbc',
- cyan: '#11a8cd',
- white: '#e5e5e5',
- brightBlack: '#666666',
- brightRed: '#f14c4c',
- brightGreen: '#23d18b',
- brightYellow: '#f5f543',
- brightBlue: '#3b8eea',
- brightMagenta: '#d670d6',
- brightCyan: '#29b8db',
- brightWhite: '#ffffff',
- extendedAnsi: [
- '#000000', '#800000', '#008000', '#808000',
- '#000080', '#800080', '#008080', '#c0c0c0',
- '#808080', '#ff0000', '#00ff00', '#ffff00',
- '#0000ff', '#ff00ff', '#00ffff', '#ffffff'
- ]
- }
- });
-
- fitAddon.current = new FitAddon();
- const webglAddon = new WebglAddon();
- const webLinksAddon = new WebLinksAddon();
-
- terminal.current.loadAddon(fitAddon.current);
- // Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
- if (!minimal) {
- terminal.current.loadAddon(webLinksAddon);
- }
- // Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
-
- try {
- terminal.current.loadAddon(webglAddon);
- } catch (error) {
- console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
- }
-
- terminal.current.open(terminalRef.current);
-
- terminal.current.attachCustomKeyEventHandler((event) => {
- const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
- ? CODEX_DEVICE_AUTH_URL
- : authUrlRef.current;
-
- if (
- event.type === 'keydown' &&
- minimal &&
- isPlainShellRef.current &&
- activeAuthUrl &&
- !event.ctrlKey &&
- !event.metaKey &&
- !event.altKey &&
- event.key?.toLowerCase() === 'c'
- ) {
- copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
- }
-
- if (
- event.type === 'keydown' &&
- (event.ctrlKey || event.metaKey) &&
- event.key?.toLowerCase() === 'c' &&
- terminal.current.hasSelection()
- ) {
- event.preventDefault();
- event.stopPropagation();
- document.execCommand('copy');
- return false;
- }
-
- if (
- event.type === 'keydown' &&
- (event.ctrlKey || event.metaKey) &&
- event.key?.toLowerCase() === 'v'
- ) {
- // Block native browser/xterm paste so clipboard data is only sent after
- // the explicit clipboard-read flow resolves (avoids duplicate pastes).
- event.preventDefault();
- event.stopPropagation();
-
- navigator.clipboard.readText().then(text => {
- if (ws.current && ws.current.readyState === WebSocket.OPEN) {
- ws.current.send(JSON.stringify({
- type: 'input',
- data: text
- }));
- }
- }).catch(() => {});
- return false;
- }
-
- return true;
- });
-
- setTimeout(() => {
- if (fitAddon.current) {
- fitAddon.current.fit();
- if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
- ws.current.send(JSON.stringify({
- type: 'resize',
- cols: terminal.current.cols,
- rows: terminal.current.rows
- }));
- }
- }
- }, 100);
-
- setIsInitialized(true);
- terminal.current.onData((data) => {
- if (ws.current && ws.current.readyState === WebSocket.OPEN) {
- ws.current.send(JSON.stringify({
- type: 'input',
- data: data
- }));
- }
- });
-
- const resizeObserver = new ResizeObserver(() => {
- if (fitAddon.current && terminal.current) {
- setTimeout(() => {
- fitAddon.current.fit();
- if (ws.current && ws.current.readyState === WebSocket.OPEN) {
- ws.current.send(JSON.stringify({
- type: 'resize',
- cols: terminal.current.cols,
- rows: terminal.current.rows
- }));
- }
- }, 50);
- }
- });
-
- if (terminalRef.current) {
- resizeObserver.observe(terminalRef.current);
- }
-
- return () => {
- resizeObserver.disconnect();
-
- if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
- ws.current.close();
- }
- ws.current = null;
-
- if (terminal.current) {
- terminal.current.dispose();
- terminal.current = null;
- }
- };
- }, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
-
- useEffect(() => {
- if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
- connectToShell();
- }, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
-
- if (!selectedProject) {
- return (
-
-
-
-
{t('shell.selectProject.title')}
-
{t('shell.selectProject.description')}
-
-
- );
- }
-
- if (minimal) {
- const displayAuthUrl = isCodexLoginCommand(initialCommand)
- ? CODEX_DEVICE_AUTH_URL
- : authUrl;
- const hasAuthUrl = Boolean(displayAuthUrl);
- const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
- const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
-
- return (
-
-
- {showMobileAuthPanel && (
-
-
-
-
Open or copy the login URL:
-
setIsAuthPanelHidden(true)}
- className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
- >
- Hide
-
-
-
event.currentTarget.select()}
- className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
- aria-label="Authentication URL"
- />
-
- {
- openAuthUrlInBrowser(displayAuthUrl);
- }}
- className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
- >
- Open URL
-
- {
- const copied = await copyAuthUrlToClipboard(displayAuthUrl);
- setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
- }}
- className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
- >
- {authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
-
-
-
-
- )}
- {showMobileAuthPanelToggle && (
-
- setIsAuthPanelHidden(false)}
- className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
- >
- Show login URL
-
-
- )}
-
- );
- }
-
- return (
-
-
-
-
-
- {selectedSession && (
-
- ({sessionDisplayNameShort}...)
-
- )}
- {!selectedSession && (
-
{t('shell.status.newSession')}
- )}
- {!isInitialized && (
-
{t('shell.status.initializing')}
- )}
- {isRestarting && (
-
{t('shell.status.restarting')}
- )}
-
-
- {isConnected && (
-
-
-
-
- {t('shell.actions.disconnect')}
-
- )}
-
-
-
-
-
- {t('shell.actions.restart')}
-
-
-
-
-
-
-
-
- {!isInitialized && (
-
- )}
-
- {isInitialized && !isConnected && !isConnecting && (
-
-
-
-
-
-
- {t('shell.actions.connect')}
-
-
- {isPlainShell ?
- t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
- selectedSession ?
- t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
- t('shell.startSession')
- }
-
-
-
- )}
-
- {isConnecting && (
-
-
-
-
-
{t('shell.connecting')}
-
-
- {isPlainShell ?
- t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
- t('shell.startCli', { projectName: selectedProject.displayName })
- }
-
-
-
- )}
-
-
- );
-}
-
-export default Shell;
diff --git a/src/components/StandaloneShell.jsx b/src/components/StandaloneShell.jsx
deleted file mode 100644
index 9f6f9f2..0000000
--- a/src/components/StandaloneShell.jsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import React, { useState, useCallback } from 'react';
-import Shell from './Shell.jsx';
-
-/**
- * Generic Shell wrapper that can be used in tabs, modals, and other contexts.
- * Provides a flexible API for both standalone and session-based usage.
- *
- * @param {Object} project - Project object with name, fullPath/path, displayName
- * @param {Object} session - Session object (optional, for tab usage)
- * @param {string} command - Initial command to run (optional)
- * @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
- * @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
- * @param {function} onComplete - Callback when process completes (receives exitCode)
- * @param {function} onClose - Callback for close button (optional)
- * @param {string} title - Custom header title (optional)
- * @param {string} className - Additional CSS classes
- * @param {boolean} showHeader - Whether to show custom header (default: true)
- * @param {boolean} compact - Use compact layout (default: false)
- * @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
- */
-function StandaloneShell({
- project,
- session = null,
- command = null,
- isPlainShell = null,
- autoConnect = true,
- onComplete = null,
- onClose = null,
- title = null,
- className = "",
- showHeader = true,
- compact = false,
- minimal = false
-}) {
- const [isCompleted, setIsCompleted] = useState(false);
-
- const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
-
- const handleProcessComplete = useCallback((exitCode) => {
- setIsCompleted(true);
- if (onComplete) {
- onComplete(exitCode);
- }
- }, [onComplete]);
-
- if (!project) {
- return (
-
-
-
-
No Project Selected
-
A project is required to open a shell
-
-
- );
- }
-
- return (
-
- {/* Optional custom header */}
- {!minimal && showHeader && title && (
-
-
-
-
{title}
- {isCompleted && (
- (Completed)
- )}
-
- {onClose && (
-
-
-
-
-
- )}
-
-
- )}
-
- {/* Shell component wrapper */}
-
-
-
-
- );
-}
-
-export default StandaloneShell;
\ No newline at end of file
diff --git a/src/components/TaskDetail.jsx b/src/components/TaskDetail.jsx
index 8316983..4d88186 100644
--- a/src/components/TaskDetail.jsx
+++ b/src/components/TaskDetail.jsx
@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
import TaskIndicator from './TaskIndicator';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
+import { copyTextToClipboard } from '../utils/clipboard';
const TaskDetail = ({
task,
@@ -79,7 +80,7 @@ const TaskDetail = ({
};
const copyTaskId = () => {
- navigator.clipboard.writeText(task.id.toString());
+ copyTextToClipboard(task.id.toString());
};
const getStatusConfig = (status) => {
diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx
index 2fae577..1e89310 100644
--- a/src/components/TaskList.jsx
+++ b/src/components/TaskList.jsx
@@ -1,10 +1,10 @@
-import React, { useState, useMemo, useEffect } from 'react';
+import React, { useState, useMemo, useEffect, useRef } from 'react';
import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react';
import { cn } from '../lib/utils';
import TaskCard from './TaskCard';
import CreateTaskModal from './CreateTaskModal';
import { useTaskMaster } from '../contexts/TaskMasterContext';
-import Shell from './Shell';
+import Shell from './shell/view/Shell';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
@@ -32,6 +32,7 @@ const TaskList = ({
const [showHelpGuide, setShowHelpGuide] = useState(false);
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
const [showPRDDropdown, setShowPRDDropdown] = useState(false);
+ const dropdownRef = useRef(null);
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
const { t } = useTranslation('tasks');
@@ -39,7 +40,11 @@ const TaskList = ({
// Close PRD dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
- if (showPRDDropdown && !event.target.closest('.relative')) {
+ if (
+ showPRDDropdown &&
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target)
+ ) {
setShowPRDDropdown(false);
}
};
@@ -48,6 +53,31 @@ const TaskList = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPRDDropdown]);
+ const loadPRDOptions = async (prd, closeDropdown = false) => {
+ if (!currentProject) {
+ return;
+ }
+
+ try {
+ const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
+ if (response.ok) {
+ const prdData = await response.json();
+ onShowPRDEditor?.({
+ name: prd.name,
+ content: prdData.content,
+ isExisting: true
+ });
+ if (closeDropdown) {
+ setShowPRDDropdown(false);
+ }
+ } else {
+ console.error('Failed to load PRD:', response.statusText);
+ }
+ } catch (error) {
+ console.error('Error loading PRD:', error);
+ }
+ };
+
// Get unique status values from tasks
const statuses = useMemo(() => {
const statusSet = new Set(tasks.map(task => task.status).filter(Boolean));
@@ -309,23 +339,8 @@ const TaskList = ({
{existingPRDs.map((prd) => (
{
- try {
- // Load the PRD content from the API
- const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
- if (response.ok) {
- const prdData = await response.json();
- onShowPRDEditor?.({
- name: prd.name,
- content: prdData.content,
- isExisting: true
- });
- } else {
- console.error('Failed to load PRD:', response.statusText);
- }
- } catch (error) {
- console.error('Error loading PRD:', error);
- }
+ onClick={() => {
+ void loadPRDOptions(prd);
}}
className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
@@ -589,7 +604,7 @@ const TaskList = ({
{/* PRD Management */}
-
+
{existingPRDs.length > 0 ? (
// Dropdown when PRDs exist
@@ -624,21 +639,8 @@ const TaskList = ({
{existingPRDs.map((prd) => (
{
- try {
- const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
- if (response.ok) {
- const prdData = await response.json();
- onShowPRDEditor?.({
- name: prd.name,
- content: prdData.content,
- isExisting: true
- });
- setShowPRDDropdown(false);
- }
- } catch (error) {
- console.error('Error loading PRD:', error);
- }
+ onClick={() => {
+ void loadPRDOptions(prd, true);
}}
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })}
@@ -1050,4 +1052,4 @@ const TaskList = ({
);
};
-export default TaskList;
\ No newline at end of file
+export default TaskList;
diff --git a/src/components/TaskMasterSetupWizard.jsx b/src/components/TaskMasterSetupWizard.jsx
index 813889b..6bcb173 100644
--- a/src/components/TaskMasterSetupWizard.jsx
+++ b/src/components/TaskMasterSetupWizard.jsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { api } from '../utils/api';
+import { copyTextToClipboard } from '../utils/clipboard';
const TaskMasterSetupWizard = ({
isOpen = true,
@@ -175,7 +176,7 @@ const TaskMasterSetupWizard = ({
}
}
}`;
- navigator.clipboard.writeText(mcpConfig);
+ copyTextToClipboard(mcpConfig);
};
const renderStepContent = () => {
diff --git a/src/components/TasksSettings.jsx b/src/components/TasksSettings.jsx
index 99f35c3..e341115 100644
--- a/src/components/TasksSettings.jsx
+++ b/src/components/TasksSettings.jsx
@@ -1,109 +1,3 @@
-import { Zap } from 'lucide-react';
-import { useTasksSettings } from '../contexts/TasksSettingsContext';
-import { useTranslation } from 'react-i18next';
+import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
-function TasksSettings() {
- const { t } = useTranslation('settings');
- const {
- tasksEnabled,
- setTasksEnabled,
- isTaskMasterInstalled,
- isCheckingInstallation
- } = useTasksSettings();
-
- return (
-
- {/* Installation Status Check */}
- {isCheckingInstallation ? (
-
-
-
-
{t('tasks.checking')}
-
-
- ) : (
- <>
- {/* TaskMaster Not Installed Warning */}
- {!isTaskMasterInstalled && (
-
-
-
-
-
- {t('tasks.notInstalled.title')}
-
-
-
{t('tasks.notInstalled.description')}
-
-
- {t('tasks.notInstalled.installCommand')}
-
-
-
-
-
-
{t('tasks.notInstalled.afterInstallation')}
-
- {t('tasks.notInstalled.steps.restart')}
- {t('tasks.notInstalled.steps.autoAvailable')}
- {t('tasks.notInstalled.steps.initCommand')}
-
-
-
-
-
-
- )}
-
- {/* TaskMaster Settings */}
- {isTaskMasterInstalled && (
-
-
-
-
-
- {t('tasks.settings.enableLabel')}
-
-
- {t('tasks.settings.enableDescription')}
-
-
-
- setTasksEnabled(e.target.checked)}
- className="sr-only peer"
- />
-
-
-
-
-
- )}
- >
- )}
-
- );
-}
-
-export default TasksSettings;
+export default TasksSettingsTab;
diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts
index ca430a1..a779bcd 100644
--- a/src/components/chat/hooks/useChatComposerState.ts
+++ b/src/components/chat/hooks/useChatComposerState.ts
@@ -47,6 +47,7 @@ interface UseChatComposerStateArgs {
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void;
+ onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
@@ -98,6 +99,7 @@ export function useChatComposerState({
sendMessage,
sendByCtrlEnter,
onSessionActive,
+ onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -569,6 +571,9 @@ export function useChatComposerState({
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
onSessionActive?.(sessionToActivate);
+ if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
+ onSessionProcessing?.(effectiveSessionId);
+ }
const getToolsSettings = () => {
try {
@@ -666,6 +671,7 @@ export function useChatComposerState({
executeCommand,
isLoading,
onSessionActive,
+ onSessionProcessing,
pendingViewSessionRef,
permissionMode,
provider,
diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts
index e84f726..6ba11cc 100644
--- a/src/components/chat/hooks/useChatRealtimeHandlers.ts
+++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts
@@ -956,12 +956,26 @@ export function useChatRealtimeHandlers({
case 'session-status': {
const statusSessionId = latestMessage.sessionId;
+ if (!statusSessionId) {
+ break;
+ }
+
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
- if (isCurrentSession && latestMessage.isProcessing) {
- setIsLoading(true);
- setCanAbortSession(true);
+
+ if (latestMessage.isProcessing) {
onSessionProcessing?.(statusSessionId);
+ if (isCurrentSession) {
+ setIsLoading(true);
+ setCanAbortSession(true);
+ }
+ break;
+ }
+
+ onSessionInactive?.(statusSessionId);
+ onSessionNotProcessing?.(statusSessionId);
+ if (isCurrentSession) {
+ clearLoadingIndicators();
}
break;
}
diff --git a/src/components/chat/tools/components/OneLineDisplay.tsx b/src/components/chat/tools/components/OneLineDisplay.tsx
index 52fc0f1..a73bd22 100644
--- a/src/components/chat/tools/components/OneLineDisplay.tsx
+++ b/src/components/chat/tools/components/OneLineDisplay.tsx
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
+import { copyTextToClipboard } from '../../../../utils/clipboard';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
interface OneLineDisplayProps {
-
toolName: string;
icon?: string;
label?: string;
@@ -25,52 +25,6 @@ interface OneLineDisplayProps {
toolId?: string;
}
-// Fallback for environments where the async Clipboard API is unavailable or blocked.
-const copyWithLegacyExecCommand = (text: string): boolean => {
- if (typeof document === 'undefined' || !document.body) {
- return false;
- }
-
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.setAttribute('readonly', '');
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- textarea.style.left = '-9999px';
- document.body.appendChild(textarea);
- textarea.select();
- textarea.setSelectionRange(0, text.length);
-
- let copied = false;
- try {
- copied = document.execCommand('copy');
- } catch {
- copied = false;
- } finally {
- document.body.removeChild(textarea);
- }
-
- return copied;
-};
-
-const copyTextToClipboard = async (text: string): Promise => {
- if (
- typeof navigator !== 'undefined' &&
- typeof window !== 'undefined' &&
- window.isSecureContext &&
- navigator.clipboard?.writeText
- ) {
- try {
- await navigator.clipboard.writeText(text);
- return true;
- } catch {
- // Fall back below when writeText is rejected (permissions/insecure contexts/browser limits).
- }
- }
-
- return copyWithLegacyExecCommand(text);
-};
-
/**
* Unified one-line display for simple tool inputs and results
* Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
@@ -92,7 +46,6 @@ export const OneLineDisplay: React.FC = ({
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
},
- resultId,
toolResult,
toolId
}) => {
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx
index 946a080..11091e5 100644
--- a/src/components/chat/view/ChatInterface.tsx
+++ b/src/components/chat/view/ChatInterface.tsx
@@ -180,6 +180,7 @@ function ChatInterface({
sendMessage,
sendByCtrlEnter,
onSessionActive,
+ onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -238,13 +239,6 @@ function ChatInterface({
};
}, [canAbortSession, handleAbortSession, isLoading]);
- useEffect(() => {
- const processingSessionId = selectedSession?.id || currentSessionId;
- if (processingSessionId && isLoading && onSessionProcessing) {
- onSessionProcessing(processingSessionId);
- }
- }, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
-
useEffect(() => {
return () => {
resetStreamingState();
diff --git a/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx
index 872dd0c..8d873cc 100644
--- a/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx
+++ b/src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx
@@ -1,5 +1,5 @@
import { SessionProvider } from '../../../../types/app';
-import SessionProviderLogo from '../../../SessionProviderLogo';
+import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { Provider } from '../../types/types';
type AssistantThinkingIndicatorProps = {
diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx
index 68afec9..6ac150d 100644
--- a/src/components/chat/view/subcomponents/ChatComposer.tsx
+++ b/src/components/chat/view/subcomponents/ChatComposer.tsx
@@ -1,6 +1,6 @@
-import CommandMenu from '../../../CommandMenu';
-import ClaudeStatus from '../../../ClaudeStatus';
-import { MicButton } from '../../../MicButton.jsx';
+import CommandMenu from './CommandMenu';
+import ClaudeStatus from './ClaudeStatus';
+import MicButton from '../../../mic-button/view/MicButton';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
@@ -151,7 +151,6 @@ export default function ChatComposer({
onTranscript,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
- const AnyCommandMenu = CommandMenu as any;
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
@@ -266,7 +265,7 @@ export default function ChatComposer({
)}
-
void;
+ isLoading: boolean;
+ provider?: string;
+};
+
+const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
+const SPINNER_CHARS = ['*', '+', 'x', '.'];
+
+export default function ClaudeStatus({
+ status,
+ onAbort,
+ isLoading,
+ provider: _provider = 'claude',
+}: ClaudeStatusProps) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0);
- // Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
@@ -15,79 +33,72 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
}
const startTime = Date.now();
- // Calculate random token rate once (30-50 tokens per second)
const tokenRate = 30 + Math.random() * 20;
- const timer = setInterval(() => {
+ const timer = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
- // Simulate token count increasing over time
setFakeTokens(Math.floor(elapsed * tokenRate));
}, 1000);
- return () => clearInterval(timer);
+ return () => window.clearInterval(timer);
}, [isLoading]);
- // Animate the status indicator
useEffect(() => {
- if (!isLoading) return;
+ if (!isLoading) {
+ return;
+ }
- const timer = setInterval(() => {
- setAnimationPhase(prev => (prev + 1) % 4);
+ const timer = window.setInterval(() => {
+ setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length);
}, 500);
- return () => clearInterval(timer);
+ return () => window.clearInterval(timer);
}, [isLoading]);
- // Don't show if loading is false
- // Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
- if (!isLoading) return null;
-
- // Clever action words that cycle
- const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
- const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
-
- // Parse status data
- const statusText = status?.text || actionWords[actionIndex];
+ if (!isLoading) {
+ return null;
+ }
+
+ const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
+ const statusText = status?.text || ACTION_WORDS[actionIndex];
const tokens = status?.tokens || fakeTokens;
const canInterrupt = status?.can_interrupt !== false;
-
- // Animation characters
- const spinners = ['✻', '✹', '✸', '✶'];
- const currentSpinner = spinners[animationPhase];
-
+ const currentSpinner = SPINNER_CHARS[animationPhase];
+
return (
- {/* Animated spinner */}
-
+
{currentSpinner}
- {/* Status text - compact for mobile */}
{statusText}...
({elapsedTime}s)
{tokens > 0 && (
<>
- ·
- ⚒ {tokens.toLocaleString()}
+ |
+
+ tokens {tokens.toLocaleString()}
+
>
)}
- ·
+ |
esc to stop
- {/* Interrupt button */}
{canInterrupt && onAbort && (
);
}
-
-export default ClaudeStatus;
\ No newline at end of file
diff --git a/src/components/chat/view/subcomponents/CommandMenu.tsx b/src/components/chat/view/subcomponents/CommandMenu.tsx
new file mode 100644
index 0000000..92a598e
--- /dev/null
+++ b/src/components/chat/view/subcomponents/CommandMenu.tsx
@@ -0,0 +1,224 @@
+import { useEffect, useRef } from 'react';
+import type { CSSProperties } from 'react';
+
+type CommandMenuCommand = {
+ name: string;
+ description?: string;
+ namespace?: string;
+ path?: string;
+ type?: string;
+ metadata?: { type?: string; [key: string]: unknown };
+ [key: string]: unknown;
+};
+
+type CommandMenuProps = {
+ commands?: CommandMenuCommand[];
+ selectedIndex?: number;
+ onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void;
+ onClose: () => void;
+ position?: { top: number; left: number; bottom?: number };
+ isOpen?: boolean;
+ frequentCommands?: CommandMenuCommand[];
+};
+
+const menuBaseStyle: CSSProperties = {
+ maxHeight: '300px',
+ overflowY: 'auto',
+ borderRadius: '8px',
+ boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
+ zIndex: 1000,
+ padding: '8px',
+ transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
+};
+
+const namespaceLabels: Record = {
+ frequent: 'Frequently Used',
+ builtin: 'Built-in Commands',
+ project: 'Project Commands',
+ user: 'User Commands',
+ other: 'Other Commands',
+};
+
+const namespaceIcons: Record = {
+ frequent: '[*]',
+ builtin: '[B]',
+ project: '[P]',
+ user: '[U]',
+ other: '[O]',
+};
+
+const getCommandKey = (command: CommandMenuCommand) =>
+ `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
+
+const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';
+
+const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
+ if (typeof window === 'undefined') {
+ return { position: 'fixed', top: '16px', left: '16px' };
+ }
+ if (window.innerWidth < 640) {
+ return {
+ position: 'fixed',
+ bottom: `${position.bottom ?? 90}px`,
+ left: '16px',
+ right: '16px',
+ width: 'auto',
+ maxWidth: 'calc(100vw - 32px)',
+ maxHeight: 'min(50vh, 300px)',
+ };
+ }
+ return {
+ position: 'fixed',
+ top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,
+ left: `${position.left}px`,
+ width: 'min(400px, calc(100vw - 32px))',
+ maxWidth: 'calc(100vw - 32px)',
+ maxHeight: '300px',
+ };
+};
+
+export default function CommandMenu({
+ commands = [],
+ selectedIndex = -1,
+ onSelect,
+ onClose,
+ position = { top: 0, left: 0 },
+ isOpen = false,
+ frequentCommands = [],
+}: CommandMenuProps) {
+ const menuRef = useRef(null);
+ const selectedItemRef = useRef(null);
+ const menuPosition = getMenuPosition(position);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!menuRef.current || !(event.target instanceof Node)) {
+ return;
+ }
+ if (!menuRef.current.contains(event.target)) {
+ onClose();
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isOpen, onClose]);
+
+ useEffect(() => {
+ if (!selectedItemRef.current || !menuRef.current) {
+ return;
+ }
+ const menuRect = menuRef.current.getBoundingClientRect();
+ const itemRect = selectedItemRef.current.getBoundingClientRect();
+ if (itemRect.bottom > menuRect.bottom || itemRect.top < menuRect.top) {
+ selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ }, [selectedIndex]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ const hasFrequentCommands = frequentCommands.length > 0;
+ const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
+ const groupedCommands = commands.reduce>((groups, command) => {
+ if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
+ return groups;
+ }
+ const namespace = getNamespace(command);
+ if (!groups[namespace]) {
+ groups[namespace] = [];
+ }
+ groups[namespace].push(command);
+ return groups;
+ }, {});
+ if (hasFrequentCommands) {
+ groupedCommands.frequent = frequentCommands;
+ }
+
+ const preferredOrder = hasFrequentCommands
+ ? ['frequent', 'builtin', 'project', 'user', 'other']
+ : ['builtin', 'project', 'user', 'other'];
+ const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
+ const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
+
+ const commandIndexByKey = new Map();
+ commands.forEach((command, index) => {
+ const key = getCommandKey(command);
+ if (!commandIndexByKey.has(key)) {
+ commandIndexByKey.set(key, index);
+ }
+ });
+
+ if (commands.length === 0) {
+ return (
+
+ No commands available
+
+ );
+ }
+
+ return (
+
+ {orderedNamespaces.map((namespace) => (
+
+ {orderedNamespaces.length > 1 && (
+
+ {namespaceLabels[namespace] || namespace}
+
+ )}
+
+ {(groupedCommands[namespace] || []).map((command) => {
+ const commandKey = getCommandKey(command);
+ const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
+ const isSelected = commandIndex === selectedIndex;
+ return (
+
onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
+ onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
+ onMouseDown={(event) => event.preventDefault()}
+ >
+
+
+ {namespaceIcons[namespace] || namespaceIcons.other}
+ {command.name}
+ {command.metadata?.type && (
+
+ {command.metadata.type}
+
+ )}
+
+ {command.description && (
+
+ {command.description}
+
+ )}
+
+ {isSelected &&
{'<-'} }
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx
index 7a0e43c..bdd6ec2 100644
--- a/src/components/chat/view/subcomponents/Markdown.tsx
+++ b/src/components/chat/view/subcomponents/Markdown.tsx
@@ -7,6 +7,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
+import { copyTextToClipboard } from '../../../../utils/clipboard';
type MarkdownProps = {
children: React.ReactNode;
@@ -31,9 +32,8 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
if (shouldInline) {
return (
{children}
@@ -43,43 +43,6 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
- const textToCopy = raw;
-
- const handleCopy = () => {
- const doSet = () => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- };
- try {
- if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
- const ta = document.createElement('textarea');
- ta.value = textToCopy;
- ta.style.position = 'fixed';
- ta.style.opacity = '0';
- document.body.appendChild(ta);
- ta.select();
- try {
- document.execCommand('copy');
- } catch {}
- document.body.removeChild(ta);
- doSet();
- });
- } else {
- const ta = document.createElement('textarea');
- ta.value = textToCopy;
- ta.style.position = 'fixed';
- ta.style.opacity = '0';
- document.body.appendChild(ta);
- ta.select();
- try {
- document.execCommand('copy');
- } catch {}
- document.body.removeChild(ta);
- doSet();
- }
- } catch {}
- };
return (
@@ -89,7 +52,14 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
+ copyTextToClipboard(raw).then((success) => {
+ if (success) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ })
+ }
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index e51ad6d..d1aa442 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -1,6 +1,6 @@
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import SessionProviderLogo from '../../../SessionProviderLogo';
+import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
ChatMessage,
ClaudePermissionSuggestion,
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
index 8f9db07..3fef5a9 100644
--- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
-import SessionProviderLogo from '../../../SessionProviderLogo';
+import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
diff --git a/src/components/code-editor/constants/settings.ts b/src/components/code-editor/constants/settings.ts
new file mode 100644
index 0000000..fe3d5d2
--- /dev/null
+++ b/src/components/code-editor/constants/settings.ts
@@ -0,0 +1,17 @@
+export const CODE_EDITOR_STORAGE_KEYS = {
+ theme: 'codeEditorTheme',
+ wordWrap: 'codeEditorWordWrap',
+ showMinimap: 'codeEditorShowMinimap',
+ lineNumbers: 'codeEditorLineNumbers',
+ fontSize: 'codeEditorFontSize',
+} as const;
+
+export const CODE_EDITOR_DEFAULTS = {
+ isDarkMode: true,
+ wordWrap: false,
+ minimapEnabled: true,
+ showLineNumbers: true,
+ fontSize: '12',
+} as const;
+
+export const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged';
diff --git a/src/components/code-editor/hooks/useCodeEditorDocument.ts b/src/components/code-editor/hooks/useCodeEditorDocument.ts
new file mode 100644
index 0000000..63fb7a0
--- /dev/null
+++ b/src/components/code-editor/hooks/useCodeEditorDocument.ts
@@ -0,0 +1,126 @@
+import { useCallback, useEffect, useState } from 'react';
+import { api } from '../../../utils/api';
+import type { CodeEditorFile } from '../types/types';
+
+type UseCodeEditorDocumentParams = {
+ file: CodeEditorFile;
+ projectPath?: string;
+};
+
+const getErrorMessage = (error: unknown) => {
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return String(error);
+};
+
+export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocumentParams) => {
+ const [content, setContent] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saveSuccess, setSaveSuccess] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+ const fileProjectName = file.projectName ?? projectPath;
+ const filePath = file.path;
+ const fileName = file.name;
+ const fileDiffNewString = file.diffInfo?.new_string;
+ const fileDiffOldString = file.diffInfo?.old_string;
+
+ useEffect(() => {
+ const loadFileContent = async () => {
+ try {
+ setLoading(true);
+
+ // Diff payload may already include full old/new snapshots, so avoid disk read.
+ if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
+ setContent(fileDiffNewString);
+ setLoading(false);
+ return;
+ }
+
+ if (!fileProjectName) {
+ throw new Error('Missing project identifier');
+ }
+
+ const response = await api.readFile(fileProjectName, filePath);
+ if (!response.ok) {
+ throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ setContent(data.content);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ console.error('Error loading file:', error);
+ setContent(`// Error loading file: ${message}\n// File: ${fileName}\n// Path: ${filePath}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadFileContent();
+ }, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
+
+ const handleSave = useCallback(async () => {
+ setSaving(true);
+ setSaveError(null);
+
+ try {
+ if (!fileProjectName) {
+ throw new Error('Missing project identifier');
+ }
+
+ const response = await api.saveFile(fileProjectName, filePath, content);
+
+ if (!response.ok) {
+ const contentType = response.headers.get('content-type');
+ if (contentType?.includes('application/json')) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Save failed: ${response.status}`);
+ }
+
+ const textError = await response.text();
+ console.error('Non-JSON error response:', textError);
+ throw new Error(`Save failed: ${response.status} ${response.statusText}`);
+ }
+
+ await response.json();
+
+ setSaveSuccess(true);
+ setTimeout(() => setSaveSuccess(false), 2000);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ console.error('Error saving file:', error);
+ setSaveError(message);
+ } finally {
+ setSaving(false);
+ }
+ }, [content, filePath, fileProjectName]);
+
+ const handleDownload = useCallback(() => {
+ const blob = new Blob([content], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+
+ anchor.href = url;
+ anchor.download = file.name;
+
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+
+ URL.revokeObjectURL(url);
+ }, [content, file.name]);
+
+ return {
+ content,
+ setContent,
+ loading,
+ saving,
+ saveSuccess,
+ saveError,
+ handleSave,
+ handleDownload,
+ };
+};
diff --git a/src/components/code-editor/hooks/useCodeEditorSettings.ts b/src/components/code-editor/hooks/useCodeEditorSettings.ts
new file mode 100644
index 0000000..639054d
--- /dev/null
+++ b/src/components/code-editor/hooks/useCodeEditorSettings.ts
@@ -0,0 +1,85 @@
+import { useEffect, useState } from 'react';
+import {
+ CODE_EDITOR_DEFAULTS,
+ CODE_EDITOR_SETTINGS_CHANGED_EVENT,
+ CODE_EDITOR_STORAGE_KEYS,
+} from '../constants/settings';
+
+const readTheme = () => {
+ const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
+ if (!savedTheme) {
+ return CODE_EDITOR_DEFAULTS.isDarkMode;
+ }
+
+ return savedTheme === 'dark';
+};
+
+const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
+ const value = localStorage.getItem(storageKey);
+ if (value === null) {
+ return defaultValue;
+ }
+
+ return value !== falseValue;
+};
+
+const readWordWrap = () => {
+ return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.wordWrap) === 'true';
+};
+
+const readFontSize = () => {
+ const stored = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize);
+ return Number(stored ?? CODE_EDITOR_DEFAULTS.fontSize);
+};
+
+export const useCodeEditorSettings = () => {
+ const [isDarkMode, setIsDarkMode] = useState(readTheme);
+ const [wordWrap, setWordWrap] = useState(readWordWrap);
+ const [minimapEnabled, setMinimapEnabled] = useState(() => (
+ readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
+ ));
+ const [showLineNumbers, setShowLineNumbers] = useState(() => (
+ readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)
+ ));
+ const [fontSize, setFontSize] = useState(readFontSize);
+
+ // Keep legacy behavior where the editor writes theme and wrap settings directly.
+ useEffect(() => {
+ localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
+ }, [isDarkMode]);
+
+ useEffect(() => {
+ localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
+ }, [wordWrap]);
+
+ useEffect(() => {
+ const refreshFromStorage = () => {
+ setIsDarkMode(readTheme());
+ setWordWrap(readWordWrap());
+ setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
+ setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
+ setFontSize(readFontSize());
+ };
+
+ window.addEventListener('storage', refreshFromStorage);
+ window.addEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
+
+ return () => {
+ window.removeEventListener('storage', refreshFromStorage);
+ window.removeEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
+ };
+ }, []);
+
+ return {
+ isDarkMode,
+ setIsDarkMode,
+ wordWrap,
+ setWordWrap,
+ minimapEnabled,
+ setMinimapEnabled,
+ showLineNumbers,
+ setShowLineNumbers,
+ fontSize,
+ setFontSize,
+ };
+};
diff --git a/src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts b/src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts
new file mode 100644
index 0000000..d98e54d
--- /dev/null
+++ b/src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts
@@ -0,0 +1,37 @@
+import { useEffect } from 'react';
+
+type UseEditorKeyboardShortcutsParams = {
+ onSave: () => void;
+ onClose: () => void;
+ dependency: string;
+};
+
+export const useEditorKeyboardShortcuts = ({
+ onSave,
+ onClose,
+ dependency,
+}: UseEditorKeyboardShortcutsParams) => {
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onClose();
+ return;
+ }
+
+ if (!(event.ctrlKey || event.metaKey)) {
+ return;
+ }
+
+ if (event.key.toLowerCase() === 's') {
+ event.preventDefault();
+ onSave();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [dependency, onClose, onSave]);
+};
diff --git a/src/components/main-content/hooks/useEditorSidebar.ts b/src/components/code-editor/hooks/useEditorSidebar.ts
similarity index 83%
rename from src/components/main-content/hooks/useEditorSidebar.ts
rename to src/components/code-editor/hooks/useEditorSidebar.ts
index e55e334..bd70edf 100644
--- a/src/components/main-content/hooks/useEditorSidebar.ts
+++ b/src/components/code-editor/hooks/useEditorSidebar.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { Project } from '../../../types/app';
-import type { DiffInfo, EditingFile } from '../types/types';
+import type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types';
type UseEditorSidebarOptions = {
selectedProject: Project | null;
@@ -9,19 +9,20 @@ type UseEditorSidebarOptions = {
initialWidth?: number;
};
-export function useEditorSidebar({
+export const useEditorSidebar = ({
selectedProject,
isMobile,
initialWidth = 600,
-}: UseEditorSidebarOptions) {
- const [editingFile, setEditingFile] = useState(null);
+}: UseEditorSidebarOptions) => {
+ const [editingFile, setEditingFile] = useState(null);
const [editorWidth, setEditorWidth] = useState(initialWidth);
const [editorExpanded, setEditorExpanded] = useState(false);
const [isResizing, setIsResizing] = useState(false);
+ const [hasManualWidth, setHasManualWidth] = useState(false);
const resizeHandleRef = useRef(null);
const handleFileOpen = useCallback(
- (filePath: string, diffInfo: DiffInfo | null = null) => {
+ (filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => {
const normalizedPath = filePath.replace(/\\/g, '/');
const fileName = normalizedPath.split('/').pop() || filePath;
@@ -41,7 +42,7 @@ export function useEditorSidebar({
}, []);
const handleToggleEditorExpand = useCallback(() => {
- setEditorExpanded((prev) => !prev);
+ setEditorExpanded((previous) => !previous);
}, []);
const handleResizeStart = useCallback(
@@ -50,6 +51,8 @@ export function useEditorSidebar({
return;
}
+ // After first drag interaction, the editor width is user-controlled.
+ setHasManualWidth(true);
setIsResizing(true);
event.preventDefault();
},
@@ -101,10 +104,11 @@ export function useEditorSidebar({
editingFile,
editorWidth,
editorExpanded,
+ hasManualWidth,
resizeHandleRef,
handleFileOpen,
handleCloseEditor,
handleToggleEditorExpand,
handleResizeStart,
};
-}
+};
diff --git a/src/components/code-editor/types/types.ts b/src/components/code-editor/types/types.ts
new file mode 100644
index 0000000..8427a5e
--- /dev/null
+++ b/src/components/code-editor/types/types.ts
@@ -0,0 +1,21 @@
+export type CodeEditorDiffInfo = {
+ old_string?: string;
+ new_string?: string;
+ [key: string]: unknown;
+};
+
+export type CodeEditorFile = {
+ name: string;
+ path: string;
+ projectName?: string;
+ diffInfo?: CodeEditorDiffInfo | null;
+ [key: string]: unknown;
+};
+
+export type CodeEditorSettingsState = {
+ isDarkMode: boolean;
+ wordWrap: boolean;
+ minimapEnabled: boolean;
+ showLineNumbers: boolean;
+ fontSize: string;
+};
diff --git a/src/components/code-editor/utils/editorExtensions.ts b/src/components/code-editor/utils/editorExtensions.ts
new file mode 100644
index 0000000..b98fd73
--- /dev/null
+++ b/src/components/code-editor/utils/editorExtensions.ts
@@ -0,0 +1,141 @@
+import { css } from '@codemirror/lang-css';
+import { html } from '@codemirror/lang-html';
+import { javascript } from '@codemirror/lang-javascript';
+import { json } from '@codemirror/lang-json';
+import { StreamLanguage } from '@codemirror/language';
+import { markdown } from '@codemirror/lang-markdown';
+import { python } from '@codemirror/lang-python';
+import { getChunks } from '@codemirror/merge';
+import { EditorView, ViewPlugin } from '@codemirror/view';
+import { showMinimap } from '@replit/codemirror-minimap';
+import type { CodeEditorFile } from '../types/types';
+
+// Lightweight lexer for `.env` files (including `.env.*` variants).
+const envLanguage = StreamLanguage.define({
+ token(stream) {
+ if (stream.match(/^#.*/)) return 'comment';
+ if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
+ if (stream.match(/^=/)) return 'operator';
+ if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
+ if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
+ if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
+ if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
+ if (stream.match(/^\d+/)) return 'number';
+
+ stream.next();
+ return null;
+ },
+});
+
+export const getLanguageExtensions = (filename: string) => {
+ const lowerName = filename.toLowerCase();
+ if (lowerName === '.env' || lowerName.startsWith('.env.')) {
+ return [envLanguage];
+ }
+
+ const ext = filename.split('.').pop()?.toLowerCase();
+ switch (ext) {
+ case 'js':
+ case 'jsx':
+ case 'ts':
+ case 'tsx':
+ return [javascript({ jsx: true, typescript: ext.includes('ts') })];
+ case 'py':
+ return [python()];
+ case 'html':
+ case 'htm':
+ return [html()];
+ case 'css':
+ case 'scss':
+ case 'less':
+ return [css()];
+ case 'json':
+ return [json()];
+ case 'md':
+ case 'markdown':
+ return [markdown()];
+ case 'env':
+ return [envLanguage];
+ default:
+ return [];
+ }
+};
+
+export const createMinimapExtension = ({
+ file,
+ showDiff,
+ minimapEnabled,
+ isDarkMode,
+}: {
+ file: CodeEditorFile;
+ showDiff: boolean;
+ minimapEnabled: boolean;
+ isDarkMode: boolean;
+}) => {
+ if (!file.diffInfo || !showDiff || !minimapEnabled) {
+ return [];
+ }
+
+ const gutters: Record = {};
+
+ return [
+ showMinimap.compute(['doc'], (state) => {
+ const chunksData = getChunks(state);
+ const chunks = chunksData?.chunks || [];
+
+ Object.keys(gutters).forEach((key) => {
+ delete gutters[Number(key)];
+ });
+
+ chunks.forEach((chunk) => {
+ const fromLine = state.doc.lineAt(chunk.fromB).number;
+ const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
+
+ for (let lineNumber = fromLine; lineNumber <= toLine; lineNumber += 1) {
+ gutters[lineNumber] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
+ }
+ });
+
+ return {
+ create: () => ({ dom: document.createElement('div') }),
+ displayText: 'blocks',
+ showOverlay: 'always',
+ gutters: [gutters],
+ };
+ }),
+ ];
+};
+
+export const createScrollToFirstChunkExtension = ({
+ file,
+ showDiff,
+}: {
+ file: CodeEditorFile;
+ showDiff: boolean;
+}) => {
+ if (!file.diffInfo || !showDiff) {
+ return [];
+ }
+
+ return [
+ ViewPlugin.fromClass(class {
+ constructor(view: EditorView) {
+ // Wait for merge decorations so the first chunk location is stable.
+ setTimeout(() => {
+ const chunksData = getChunks(view.state);
+ const firstChunk = chunksData?.chunks?.[0];
+
+ if (firstChunk) {
+ view.dispatch({
+ effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }),
+ });
+ }
+ }, 100);
+ }
+
+ update() {}
+
+ destroy() {}
+ }),
+ ];
+};
diff --git a/src/components/code-editor/utils/editorStyles.ts b/src/components/code-editor/utils/editorStyles.ts
new file mode 100644
index 0000000..b3a6c61
--- /dev/null
+++ b/src/components/code-editor/utils/editorStyles.ts
@@ -0,0 +1,79 @@
+export const getEditorLoadingStyles = (isDarkMode: boolean) => {
+ return `
+ .code-editor-loading {
+ background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
+ }
+
+ .code-editor-loading:hover {
+ background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
+ }
+ `;
+};
+
+export const getEditorStyles = (isDarkMode: boolean) => {
+ return `
+ .cm-deletedChunk {
+ background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
+ border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
+ padding-left: 4px !important;
+ }
+
+ .cm-insertedChunk {
+ background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
+ border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
+ padding-left: 4px !important;
+ }
+
+ .cm-editor.cm-merge-b .cm-changedText {
+ background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
+ padding-top: 2px !important;
+ padding-bottom: 2px !important;
+ margin-top: -2px !important;
+ margin-bottom: -2px !important;
+ }
+
+ .cm-editor .cm-deletedChunk .cm-changedText {
+ background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
+ padding-top: 2px !important;
+ padding-bottom: 2px !important;
+ margin-top: -2px !important;
+ margin-bottom: -2px !important;
+ }
+
+ .cm-gutter.cm-gutter-minimap {
+ background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
+ }
+
+ .cm-editor-toolbar-panel {
+ padding: 4px 10px;
+ background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
+ border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
+ color: ${isDarkMode ? '#d1d5db' : '#374151'};
+ font-size: 12px;
+ }
+
+ .cm-diff-nav-btn,
+ .cm-toolbar-btn {
+ padding: 3px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ border-radius: 4px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: inherit;
+ transition: background-color 0.2s;
+ }
+
+ .cm-diff-nav-btn:hover,
+ .cm-toolbar-btn:hover {
+ background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
+ }
+
+ .cm-diff-nav-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ `;
+};
diff --git a/src/components/code-editor/utils/editorToolbarPanel.ts b/src/components/code-editor/utils/editorToolbarPanel.ts
new file mode 100644
index 0000000..4f03794
--- /dev/null
+++ b/src/components/code-editor/utils/editorToolbarPanel.ts
@@ -0,0 +1,212 @@
+import { getChunks } from '@codemirror/merge';
+import { EditorView, showPanel } from '@codemirror/view';
+import type { CodeEditorFile } from '../types/types';
+
+type EditorToolbarLabels = {
+ changes: string;
+ previousChange: string;
+ nextChange: string;
+ hideDiff: string;
+ showDiff: string;
+ collapse: string;
+ expand: string;
+};
+
+type CreateEditorToolbarPanelParams = {
+ file: CodeEditorFile;
+ showDiff: boolean;
+ isSidebar: boolean;
+ isExpanded: boolean;
+ onToggleDiff: () => void;
+ onPopOut: (() => void) | null;
+ onToggleExpand: (() => void) | null;
+ labels: EditorToolbarLabels;
+};
+
+const getDiffVisibilityIcon = (showDiff: boolean) => {
+ if (showDiff) {
+ return ' ';
+ }
+
+ return ' ';
+};
+
+const getExpandIcon = (isExpanded: boolean) => {
+ if (isExpanded) {
+ return ' ';
+ }
+
+ return ' ';
+};
+
+const escapeHtml = (value: string): string => (
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+);
+
+export const createEditorToolbarPanelExtension = ({
+ file,
+ showDiff,
+ isSidebar,
+ isExpanded,
+ onToggleDiff,
+ onPopOut,
+ onToggleExpand,
+ labels,
+}: CreateEditorToolbarPanelParams) => {
+ const hasToolbarButtons = Boolean(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
+ if (!hasToolbarButtons) {
+ return [];
+ }
+
+ const createPanel = (view: EditorView) => {
+ const dom = document.createElement('div');
+ dom.className = 'cm-editor-toolbar-panel';
+
+ let currentIndex = 0;
+
+ const updatePanel = () => {
+ const hasDiff = Boolean(file.diffInfo && showDiff);
+ const chunksData = hasDiff ? getChunks(view.state) : null;
+ const chunks = chunksData?.chunks || [];
+ const chunkCount = chunks.length;
+ const maxChunkIndex = Math.max(0, chunkCount - 1);
+ currentIndex = Math.max(0, Math.min(currentIndex, maxChunkIndex));
+ const escapedLabels = {
+ changes: escapeHtml(labels.changes),
+ previousChange: escapeHtml(labels.previousChange),
+ nextChange: escapeHtml(labels.nextChange),
+ hideDiff: escapeHtml(labels.hideDiff),
+ showDiff: escapeHtml(labels.showDiff),
+ collapse: escapeHtml(labels.collapse),
+ expand: escapeHtml(labels.expand),
+ };
+ // Icons are static SVG path fragments controlled by this module.
+ const diffVisibilityIcon = getDiffVisibilityIcon(showDiff);
+ const expandIcon = getExpandIcon(isExpanded);
+
+ let toolbarHtml = '';
+ toolbarHtml += '
';
+
+ if (hasDiff) {
+ toolbarHtml += `
+
${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${escapedLabels.changes}
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ toolbarHtml += '
';
+ toolbarHtml += '
';
+
+ if (file.diffInfo) {
+ toolbarHtml += `
+
+
+ ${diffVisibilityIcon}
+
+
+ `;
+ }
+
+ if (isSidebar && onPopOut) {
+ toolbarHtml += `
+
+
+
+
+
+ `;
+ }
+
+ if (isSidebar && onToggleExpand) {
+ toolbarHtml += `
+
+
+ ${expandIcon}
+
+
+ `;
+ }
+
+ toolbarHtml += '
';
+ toolbarHtml += '
';
+
+ dom.innerHTML = toolbarHtml;
+
+ if (hasDiff) {
+ const previousButton = dom.querySelector('.cm-diff-nav-prev');
+ const nextButton = dom.querySelector('.cm-diff-nav-next');
+
+ previousButton?.addEventListener('click', () => {
+ if (chunks.length === 0) {
+ return;
+ }
+
+ currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
+ const chunk = chunks[currentIndex];
+
+ if (chunk) {
+ view.dispatch({
+ effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
+ });
+ }
+
+ updatePanel();
+ });
+
+ nextButton?.addEventListener('click', () => {
+ if (chunks.length === 0) {
+ return;
+ }
+
+ currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
+ const chunk = chunks[currentIndex];
+
+ if (chunk) {
+ view.dispatch({
+ effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
+ });
+ }
+
+ updatePanel();
+ });
+ }
+
+ const toggleDiffButton = dom.querySelector('.cm-toggle-diff-btn');
+ toggleDiffButton?.addEventListener('click', onToggleDiff);
+
+ const popOutButton = dom.querySelector('.cm-popout-btn');
+ popOutButton?.addEventListener('click', () => {
+ onPopOut?.();
+ });
+
+ const expandButton = dom.querySelector('.cm-expand-btn');
+ expandButton?.addEventListener('click', () => {
+ onToggleExpand?.();
+ });
+ };
+
+ updatePanel();
+
+ return {
+ top: true,
+ dom,
+ update: updatePanel,
+ };
+ };
+
+ return [showPanel.of(createPanel)];
+};
diff --git a/src/components/code-editor/view/CodeEditor.tsx b/src/components/code-editor/view/CodeEditor.tsx
new file mode 100644
index 0000000..c1f77f8
--- /dev/null
+++ b/src/components/code-editor/view/CodeEditor.tsx
@@ -0,0 +1,234 @@
+import { EditorView } from '@codemirror/view';
+import { unifiedMergeView } from '@codemirror/merge';
+import type { Extension } from '@codemirror/state';
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
+import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
+import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
+import type { CodeEditorFile } from '../types/types';
+import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
+import { getEditorStyles } from '../utils/editorStyles';
+import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
+import CodeEditorFooter from './subcomponents/CodeEditorFooter';
+import CodeEditorHeader from './subcomponents/CodeEditorHeader';
+import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
+import CodeEditorSurface from './subcomponents/CodeEditorSurface';
+
+type CodeEditorProps = {
+ file: CodeEditorFile;
+ onClose: () => void;
+ projectPath?: string;
+ isSidebar?: boolean;
+ isExpanded?: boolean;
+ onToggleExpand?: (() => void) | null;
+ onPopOut?: (() => void) | null;
+};
+
+export default function CodeEditor({
+ file,
+ onClose,
+ projectPath,
+ isSidebar = false,
+ isExpanded = false,
+ onToggleExpand = null,
+ onPopOut = null,
+}: CodeEditorProps) {
+ const { t } = useTranslation('codeEditor');
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
+ const [markdownPreview, setMarkdownPreview] = useState(false);
+
+ const {
+ isDarkMode,
+ wordWrap,
+ minimapEnabled,
+ showLineNumbers,
+ fontSize,
+ } = useCodeEditorSettings();
+
+ const {
+ content,
+ setContent,
+ loading,
+ saving,
+ saveSuccess,
+ saveError,
+ handleSave,
+ handleDownload,
+ } = useCodeEditorDocument({
+ file,
+ projectPath,
+ });
+
+ const isMarkdownFile = useMemo(() => {
+ const extension = file.name.split('.').pop()?.toLowerCase();
+ return extension === 'md' || extension === 'markdown';
+ }, [file.name]);
+
+ const minimapExtension = useMemo(
+ () => (
+ createMinimapExtension({
+ file,
+ showDiff,
+ minimapEnabled,
+ isDarkMode,
+ })
+ ),
+ [file, isDarkMode, minimapEnabled, showDiff],
+ );
+
+ const scrollToFirstChunkExtension = useMemo(
+ () => createScrollToFirstChunkExtension({ file, showDiff }),
+ [file, showDiff],
+ );
+
+ const toolbarPanelExtension = useMemo(
+ () => (
+ createEditorToolbarPanelExtension({
+ file,
+ showDiff,
+ isSidebar,
+ isExpanded,
+ onToggleDiff: () => setShowDiff((previous) => !previous),
+ onPopOut,
+ onToggleExpand,
+ labels: {
+ changes: t('toolbar.changes'),
+ previousChange: t('toolbar.previousChange'),
+ nextChange: t('toolbar.nextChange'),
+ hideDiff: t('toolbar.hideDiff'),
+ showDiff: t('toolbar.showDiff'),
+ collapse: t('toolbar.collapse'),
+ expand: t('toolbar.expand'),
+ },
+ })
+ ),
+ [file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t],
+ );
+
+ const extensions = useMemo(() => {
+ const allExtensions: Extension[] = [
+ ...getLanguageExtensions(file.name),
+ ...toolbarPanelExtension,
+ ];
+
+ if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) {
+ allExtensions.push(
+ unifiedMergeView({
+ original: file.diffInfo.old_string,
+ mergeControls: false,
+ highlightChanges: true,
+ syntaxHighlightDeletions: false,
+ gutter: true,
+ }),
+ );
+ allExtensions.push(...minimapExtension);
+ allExtensions.push(...scrollToFirstChunkExtension);
+ }
+
+ if (wordWrap) {
+ allExtensions.push(EditorView.lineWrapping);
+ }
+
+ return allExtensions;
+ }, [
+ file.diffInfo,
+ file.name,
+ minimapExtension,
+ scrollToFirstChunkExtension,
+ showDiff,
+ toolbarPanelExtension,
+ wordWrap,
+ ]);
+
+ useEditorKeyboardShortcuts({
+ onSave: handleSave,
+ onClose,
+ dependency: content,
+ });
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ const outerContainerClassName = isSidebar
+ ? 'w-full h-full flex flex-col'
+ : `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;
+
+ const innerContainerClassName = isSidebar
+ ? 'bg-background flex flex-col w-full h-full'
+ : `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${
+ isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]'
+ }`;
+
+ return (
+ <>
+
+
+
+
setMarkdownPreview((previous) => !previous)}
+ onOpenSettings={() => window.openSettings?.('appearance')}
+ onDownload={handleDownload}
+ onSave={handleSave}
+ onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
+ onClose={onClose}
+ labels={{
+ showingChanges: t('header.showingChanges'),
+ editMarkdown: t('actions.editMarkdown'),
+ previewMarkdown: t('actions.previewMarkdown'),
+ settings: t('toolbar.settings'),
+ download: t('actions.download'),
+ save: t('actions.save'),
+ saving: t('actions.saving'),
+ saved: t('actions.saved'),
+ fullscreen: t('actions.fullscreen'),
+ exitFullscreen: t('actions.exitFullscreen'),
+ close: t('actions.close'),
+ }}
+ />
+
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/main-content/view/subcomponents/EditorSidebar.tsx b/src/components/code-editor/view/EditorSidebar.tsx
similarity index 62%
rename from src/components/main-content/view/subcomponents/EditorSidebar.tsx
rename to src/components/code-editor/view/EditorSidebar.tsx
index ea6cf70..d08e2b0 100644
--- a/src/components/main-content/view/subcomponents/EditorSidebar.tsx
+++ b/src/components/code-editor/view/EditorSidebar.tsx
@@ -1,14 +1,28 @@
import { useState } from 'react';
-import CodeEditor from '../../../CodeEditor';
-import type { EditorSidebarProps } from '../../types/types';
+import type { MouseEvent, MutableRefObject } from 'react';
+import type { CodeEditorFile } from '../types/types';
+import CodeEditor from './CodeEditor';
-const AnyCodeEditor = CodeEditor as any;
+type EditorSidebarProps = {
+ editingFile: CodeEditorFile | null;
+ isMobile: boolean;
+ editorExpanded: boolean;
+ editorWidth: number;
+ hasManualWidth: boolean;
+ resizeHandleRef: MutableRefObject;
+ onResizeStart: (event: MouseEvent) => void;
+ onCloseEditor: () => void;
+ onToggleEditorExpand: () => void;
+ projectPath?: string;
+ fillSpace?: boolean;
+};
export default function EditorSidebar({
editingFile,
isMobile,
editorExpanded,
editorWidth,
+ hasManualWidth,
resizeHandleRef,
onResizeStart,
onCloseEditor,
@@ -24,7 +38,7 @@ export default function EditorSidebar({
if (isMobile || poppedOut) {
return (
- {
setPoppedOut(false);
@@ -36,7 +50,8 @@ export default function EditorSidebar({
);
}
- const useFlex = editorExpanded || fillSpace;
+ // In files tab, fill the remaining width unless user has dragged manually.
+ const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return (
<>
@@ -52,10 +67,10 @@ export default function EditorSidebar({
)}
-
+
+
+ {linesLabel} {content.split('\n').length}
+
+
+ {charactersLabel} {content.length}
+
+
+
+ {shortcutsLabel}
+
+ );
+}
diff --git a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx
new file mode 100644
index 0000000..41c7d21
--- /dev/null
+++ b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx
@@ -0,0 +1,143 @@
+import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
+import type { CodeEditorFile } from '../../types/types';
+
+type CodeEditorHeaderProps = {
+ file: CodeEditorFile;
+ isSidebar: boolean;
+ isFullscreen: boolean;
+ isMarkdownFile: boolean;
+ markdownPreview: boolean;
+ saving: boolean;
+ saveSuccess: boolean;
+ onToggleMarkdownPreview: () => void;
+ onOpenSettings: () => void;
+ onDownload: () => void;
+ onSave: () => void;
+ onToggleFullscreen: () => void;
+ onClose: () => void;
+ labels: {
+ showingChanges: string;
+ editMarkdown: string;
+ previewMarkdown: string;
+ settings: string;
+ download: string;
+ save: string;
+ saving: string;
+ saved: string;
+ fullscreen: string;
+ exitFullscreen: string;
+ close: string;
+ };
+};
+
+export default function CodeEditorHeader({
+ file,
+ isSidebar,
+ isFullscreen,
+ isMarkdownFile,
+ markdownPreview,
+ saving,
+ saveSuccess,
+ onToggleMarkdownPreview,
+ onOpenSettings,
+ onDownload,
+ onSave,
+ onToggleFullscreen,
+ onClose,
+ labels,
+}: CodeEditorHeaderProps) {
+ const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
+
+ return (
+
+
+
+
+
{file.name}
+ {file.diffInfo && (
+
+ {labels.showingChanges}
+
+ )}
+
+
{file.path}
+
+
+
+
+ {isMarkdownFile && (
+
+ {markdownPreview ? : }
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {saveSuccess ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {!isSidebar && (
+
+ {isFullscreen ? : }
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx b/src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx
new file mode 100644
index 0000000..8f2718b
--- /dev/null
+++ b/src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx
@@ -0,0 +1,36 @@
+import { getEditorLoadingStyles } from '../../utils/editorStyles';
+
+type CodeEditorLoadingStateProps = {
+ isDarkMode: boolean;
+ isSidebar: boolean;
+ loadingText: string;
+};
+
+export default function CodeEditorLoadingState({
+ isDarkMode,
+ isSidebar,
+ loadingText,
+}: CodeEditorLoadingStateProps) {
+ return (
+ <>
+
+ {isSidebar ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx b/src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx
new file mode 100644
index 0000000..91d578b
--- /dev/null
+++ b/src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx
@@ -0,0 +1,62 @@
+import CodeMirror from '@uiw/react-codemirror';
+import { oneDark } from '@codemirror/theme-one-dark';
+import type { Extension } from '@codemirror/state';
+import MarkdownPreview from './markdown/MarkdownPreview';
+
+type CodeEditorSurfaceProps = {
+ content: string;
+ onChange: (value: string) => void;
+ markdownPreview: boolean;
+ isMarkdownFile: boolean;
+ isDarkMode: boolean;
+ fontSize: number;
+ showLineNumbers: boolean;
+ extensions: Extension[];
+};
+
+export default function CodeEditorSurface({
+ content,
+ onChange,
+ markdownPreview,
+ isMarkdownFile,
+ isDarkMode,
+ fontSize,
+ showLineNumbers,
+ extensions,
+}: CodeEditorSurfaceProps) {
+ if (markdownPreview && isMarkdownFile) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx b/src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx
new file mode 100644
index 0000000..4bd905c
--- /dev/null
+++ b/src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx
@@ -0,0 +1,72 @@
+import { useState } from 'react';
+import type { ComponentProps } from 'react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
+import { copyTextToClipboard } from '../../../../../utils/clipboard';
+
+type MarkdownCodeBlockProps = {
+ inline?: boolean;
+ node?: unknown;
+} & ComponentProps<'code'>;
+
+export default function MarkdownCodeBlock({
+ inline,
+ className,
+ children,
+ node: _node,
+ ...props
+}: MarkdownCodeBlockProps) {
+ const [copied, setCopied] = useState(false);
+ const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
+ const looksMultiline = /[\r\n]/.test(rawContent);
+ const shouldRenderInline = inline || !looksMultiline;
+
+ if (shouldRenderInline) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ const languageMatch = /language-(\w+)/.exec(className || '');
+ const language = languageMatch ? languageMatch[1] : 'text';
+
+ return (
+
+ {language !== 'text' && (
+
{language}
+ )}
+
+
+ copyTextToClipboard(rawContent).then((success) => {
+ if (success) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ })}
+ className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
+ >
+ {copied ? 'Copied!' : 'Copy'}
+
+
+
+ {rawContent}
+
+
+ );
+}
diff --git a/src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx b/src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx
new file mode 100644
index 0000000..96c8d89
--- /dev/null
+++ b/src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx
@@ -0,0 +1,52 @@
+import { useMemo } from 'react';
+import type { Components } from 'react-markdown';
+import ReactMarkdown from 'react-markdown';
+import rehypeKatex from 'rehype-katex';
+import remarkGfm from 'remark-gfm';
+import remarkMath from 'remark-math';
+import MarkdownCodeBlock from './MarkdownCodeBlock';
+
+type MarkdownPreviewProps = {
+ content: string;
+};
+
+const markdownPreviewComponents: Components = {
+ code: MarkdownCodeBlock,
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ table: ({ children }) => (
+
+ ),
+ thead: ({ children }) => {children} ,
+ th: ({ children }) => (
+ {children}
+ ),
+ td: ({ children }) => (
+ {children}
+ ),
+};
+
+export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
+ const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
+ const rehypePlugins = useMemo(() => [rehypeKatex], []);
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/components/file-tree/constants/constants.ts b/src/components/file-tree/constants/constants.ts
new file mode 100644
index 0000000..a56bf6b
--- /dev/null
+++ b/src/components/file-tree/constants/constants.ts
@@ -0,0 +1,18 @@
+import type { FileTreeViewMode } from '../types/types';
+
+export const FILE_TREE_VIEW_MODE_STORAGE_KEY = 'file-tree-view-mode';
+
+export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
+
+export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
+
+export const IMAGE_FILE_EXTENSIONS = new Set([
+ 'png',
+ 'jpg',
+ 'jpeg',
+ 'gif',
+ 'svg',
+ 'webp',
+ 'ico',
+ 'bmp',
+]);
diff --git a/src/components/file-tree/constants/fileIcons.ts b/src/components/file-tree/constants/fileIcons.ts
new file mode 100644
index 0000000..eca92da
--- /dev/null
+++ b/src/components/file-tree/constants/fileIcons.ts
@@ -0,0 +1,224 @@
+import {
+ Archive,
+ Binary,
+ Blocks,
+ BookOpen,
+ Box,
+ Braces,
+ Code2,
+ Cog,
+ Coffee,
+ Cpu,
+ Database,
+ File,
+ FileCheck,
+ FileCode,
+ FileCode2,
+ FileSpreadsheet,
+ FileText,
+ FileType,
+ Flame,
+ FlaskConical,
+ Gem,
+ Globe,
+ Hash,
+ Hexagon,
+ Image,
+ Lock,
+ Music2,
+ NotebookPen,
+ Palette,
+ Scroll,
+ Settings,
+ Shield,
+ SquareFunction,
+ Terminal,
+ Video,
+ Workflow,
+} from 'lucide-react';
+import type { FileIconData, FileIconMap } from '../types/types';
+
+export const ICON_SIZE_CLASS = 'w-4 h-4 flex-shrink-0';
+
+const FILE_ICON_MAP: FileIconMap = {
+ js: { icon: FileCode, color: 'text-yellow-500' },
+ jsx: { icon: FileCode, color: 'text-yellow-500' },
+ mjs: { icon: FileCode, color: 'text-yellow-500' },
+ cjs: { icon: FileCode, color: 'text-yellow-500' },
+ ts: { icon: FileCode2, color: 'text-blue-500' },
+ tsx: { icon: FileCode2, color: 'text-blue-500' },
+ mts: { icon: FileCode2, color: 'text-blue-500' },
+ py: { icon: Code2, color: 'text-emerald-500' },
+ pyw: { icon: Code2, color: 'text-emerald-500' },
+ pyi: { icon: Code2, color: 'text-emerald-400' },
+ ipynb: { icon: NotebookPen, color: 'text-orange-500' },
+ rs: { icon: Cog, color: 'text-orange-600' },
+ toml: { icon: Settings, color: 'text-gray-500' },
+ go: { icon: Hexagon, color: 'text-cyan-500' },
+ rb: { icon: Gem, color: 'text-red-500' },
+ erb: { icon: Gem, color: 'text-red-400' },
+ php: { icon: Blocks, color: 'text-violet-500' },
+ java: { icon: Coffee, color: 'text-red-600' },
+ jar: { icon: Coffee, color: 'text-red-500' },
+ kt: { icon: Hexagon, color: 'text-violet-500' },
+ kts: { icon: Hexagon, color: 'text-violet-400' },
+ c: { icon: Cpu, color: 'text-blue-600' },
+ h: { icon: Cpu, color: 'text-blue-400' },
+ cpp: { icon: Cpu, color: 'text-blue-700' },
+ hpp: { icon: Cpu, color: 'text-blue-500' },
+ cc: { icon: Cpu, color: 'text-blue-700' },
+ cs: { icon: Hexagon, color: 'text-purple-600' },
+ swift: { icon: Flame, color: 'text-orange-500' },
+ lua: { icon: SquareFunction, color: 'text-blue-500' },
+ r: { icon: FlaskConical, color: 'text-blue-600' },
+ html: { icon: Globe, color: 'text-orange-600' },
+ htm: { icon: Globe, color: 'text-orange-600' },
+ css: { icon: Hash, color: 'text-blue-500' },
+ scss: { icon: Hash, color: 'text-pink-500' },
+ sass: { icon: Hash, color: 'text-pink-400' },
+ less: { icon: Hash, color: 'text-indigo-500' },
+ vue: { icon: FileCode2, color: 'text-emerald-500' },
+ svelte: { icon: FileCode2, color: 'text-orange-500' },
+ json: { icon: Braces, color: 'text-yellow-600' },
+ jsonc: { icon: Braces, color: 'text-yellow-500' },
+ json5: { icon: Braces, color: 'text-yellow-500' },
+ yaml: { icon: Settings, color: 'text-purple-400' },
+ yml: { icon: Settings, color: 'text-purple-400' },
+ xml: { icon: FileCode, color: 'text-orange-500' },
+ csv: { icon: FileSpreadsheet, color: 'text-green-600' },
+ tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
+ sql: { icon: Database, color: 'text-blue-500' },
+ graphql: { icon: Workflow, color: 'text-pink-500' },
+ gql: { icon: Workflow, color: 'text-pink-500' },
+ proto: { icon: Box, color: 'text-green-500' },
+ env: { icon: Shield, color: 'text-yellow-600' },
+ md: { icon: BookOpen, color: 'text-blue-500' },
+ mdx: { icon: BookOpen, color: 'text-blue-400' },
+ txt: { icon: FileText, color: 'text-gray-500' },
+ doc: { icon: FileText, color: 'text-blue-600' },
+ docx: { icon: FileText, color: 'text-blue-600' },
+ pdf: { icon: FileCheck, color: 'text-red-600' },
+ rtf: { icon: FileText, color: 'text-gray-500' },
+ tex: { icon: Scroll, color: 'text-teal-600' },
+ rst: { icon: FileText, color: 'text-gray-400' },
+ sh: { icon: Terminal, color: 'text-green-500' },
+ bash: { icon: Terminal, color: 'text-green-500' },
+ zsh: { icon: Terminal, color: 'text-green-400' },
+ fish: { icon: Terminal, color: 'text-green-400' },
+ ps1: { icon: Terminal, color: 'text-blue-400' },
+ bat: { icon: Terminal, color: 'text-gray-500' },
+ cmd: { icon: Terminal, color: 'text-gray-500' },
+ png: { icon: Image, color: 'text-purple-500' },
+ jpg: { icon: Image, color: 'text-purple-500' },
+ jpeg: { icon: Image, color: 'text-purple-500' },
+ gif: { icon: Image, color: 'text-purple-400' },
+ webp: { icon: Image, color: 'text-purple-400' },
+ ico: { icon: Image, color: 'text-purple-400' },
+ bmp: { icon: Image, color: 'text-purple-400' },
+ tiff: { icon: Image, color: 'text-purple-400' },
+ svg: { icon: Palette, color: 'text-amber-500' },
+ mp3: { icon: Music2, color: 'text-pink-500' },
+ wav: { icon: Music2, color: 'text-pink-500' },
+ ogg: { icon: Music2, color: 'text-pink-400' },
+ flac: { icon: Music2, color: 'text-pink-400' },
+ aac: { icon: Music2, color: 'text-pink-400' },
+ m4a: { icon: Music2, color: 'text-pink-400' },
+ mp4: { icon: Video, color: 'text-rose-500' },
+ mov: { icon: Video, color: 'text-rose-500' },
+ avi: { icon: Video, color: 'text-rose-500' },
+ webm: { icon: Video, color: 'text-rose-400' },
+ mkv: { icon: Video, color: 'text-rose-400' },
+ ttf: { icon: FileType, color: 'text-red-500' },
+ otf: { icon: FileType, color: 'text-red-500' },
+ woff: { icon: FileType, color: 'text-red-400' },
+ woff2: { icon: FileType, color: 'text-red-400' },
+ eot: { icon: FileType, color: 'text-red-400' },
+ zip: { icon: Archive, color: 'text-amber-600' },
+ tar: { icon: Archive, color: 'text-amber-600' },
+ gz: { icon: Archive, color: 'text-amber-600' },
+ bz2: { icon: Archive, color: 'text-amber-600' },
+ rar: { icon: Archive, color: 'text-amber-500' },
+ '7z': { icon: Archive, color: 'text-amber-500' },
+ lock: { icon: Lock, color: 'text-gray-500' },
+ exe: { icon: Binary, color: 'text-gray-500' },
+ bin: { icon: Binary, color: 'text-gray-500' },
+ dll: { icon: Binary, color: 'text-gray-400' },
+ so: { icon: Binary, color: 'text-gray-400' },
+ dylib: { icon: Binary, color: 'text-gray-400' },
+ wasm: { icon: Binary, color: 'text-purple-500' },
+ ini: { icon: Settings, color: 'text-gray-500' },
+ cfg: { icon: Settings, color: 'text-gray-500' },
+ conf: { icon: Settings, color: 'text-gray-500' },
+ log: { icon: Scroll, color: 'text-gray-400' },
+ map: { icon: File, color: 'text-gray-400' },
+};
+
+const FILENAME_ICON_MAP: FileIconMap = {
+ Dockerfile: { icon: Box, color: 'text-blue-500' },
+ 'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
+ 'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
+ '.dockerignore': { icon: Box, color: 'text-gray-500' },
+ '.gitignore': { icon: Settings, color: 'text-gray-500' },
+ '.gitmodules': { icon: Settings, color: 'text-gray-500' },
+ '.gitattributes': { icon: Settings, color: 'text-gray-500' },
+ '.editorconfig': { icon: Settings, color: 'text-gray-500' },
+ '.prettierrc': { icon: Settings, color: 'text-pink-400' },
+ '.prettierignore': { icon: Settings, color: 'text-gray-500' },
+ '.eslintrc': { icon: Settings, color: 'text-violet-500' },
+ '.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
+ '.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
+ '.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
+ 'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
+ 'eslint.config.mjs': { icon: Settings, color: 'text-violet-500' },
+ '.env': { icon: Shield, color: 'text-yellow-600' },
+ '.env.local': { icon: Shield, color: 'text-yellow-600' },
+ '.env.development': { icon: Shield, color: 'text-yellow-500' },
+ '.env.production': { icon: Shield, color: 'text-yellow-600' },
+ '.env.example': { icon: Shield, color: 'text-yellow-400' },
+ 'package.json': { icon: Braces, color: 'text-green-500' },
+ 'package-lock.json': { icon: Lock, color: 'text-gray-500' },
+ 'yarn.lock': { icon: Lock, color: 'text-blue-400' },
+ 'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
+ 'bun.lockb': { icon: Lock, color: 'text-gray-400' },
+ 'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
+ 'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
+ Gemfile: { icon: Gem, color: 'text-red-500' },
+ 'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
+ Makefile: { icon: Terminal, color: 'text-gray-500' },
+ 'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
+ 'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
+ 'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
+ 'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
+ 'vite.config.js': { icon: Flame, color: 'text-purple-500' },
+ 'webpack.config.js': { icon: Cog, color: 'text-blue-500' },
+ 'tailwind.config.js': { icon: Hash, color: 'text-cyan-500' },
+ 'tailwind.config.ts': { icon: Hash, color: 'text-cyan-500' },
+ 'postcss.config.js': { icon: Cog, color: 'text-red-400' },
+ 'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
+ '.babelrc': { icon: Settings, color: 'text-yellow-500' },
+ 'README.md': { icon: BookOpen, color: 'text-blue-500' },
+ LICENSE: { icon: FileCheck, color: 'text-gray-500' },
+ 'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
+ 'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
+ 'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
+ 'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
+ 'go.sum': { icon: Lock, color: 'text-cyan-400' },
+};
+
+// Icon resolution is deterministic: exact filename, then .env prefixes, then extension, then fallback.
+export function getFileIconData(filename: string): FileIconData {
+ if (FILENAME_ICON_MAP[filename]) {
+ return FILENAME_ICON_MAP[filename];
+ }
+
+ if (filename.startsWith('.env')) {
+ return { icon: Shield, color: 'text-yellow-600' };
+ }
+
+ const extension = filename.split('.').pop()?.toLowerCase();
+ if (extension && FILE_ICON_MAP[extension]) {
+ return FILE_ICON_MAP[extension];
+ }
+
+ return { icon: File, color: 'text-muted-foreground' };
+}
diff --git a/src/components/file-tree/hooks/useExpandedDirectories.ts b/src/components/file-tree/hooks/useExpandedDirectories.ts
new file mode 100644
index 0000000..df1c388
--- /dev/null
+++ b/src/components/file-tree/hooks/useExpandedDirectories.ts
@@ -0,0 +1,44 @@
+import { useCallback, useState } from 'react';
+
+type UseExpandedDirectoriesResult = {
+ expandedDirs: Set;
+ toggleDirectory: (path: string) => void;
+ expandDirectories: (paths: string[]) => void;
+};
+
+export function useExpandedDirectories(): UseExpandedDirectoriesResult {
+ const [expandedDirs, setExpandedDirs] = useState>(() => new Set());
+
+ const toggleDirectory = useCallback((path: string) => {
+ setExpandedDirs((previous) => {
+ const next = new Set(previous);
+
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+
+ return next;
+ });
+ }, []);
+
+ const expandDirectories = useCallback((paths: string[]) => {
+ if (paths.length === 0) {
+ return;
+ }
+
+ setExpandedDirs((previous) => {
+ const next = new Set(previous);
+ paths.forEach((path) => next.add(path));
+ return next;
+ });
+ }, []);
+
+ return {
+ expandedDirs,
+ toggleDirectory,
+ expandDirectories,
+ };
+}
+
diff --git a/src/components/file-tree/hooks/useFileTreeData.ts b/src/components/file-tree/hooks/useFileTreeData.ts
new file mode 100644
index 0000000..3ec4e94
--- /dev/null
+++ b/src/components/file-tree/hooks/useFileTreeData.ts
@@ -0,0 +1,76 @@
+import { useEffect, useState } from 'react';
+import { api } from '../../../utils/api';
+import type { Project } from '../../../types/app';
+import type { FileTreeNode } from '../types/types';
+
+type UseFileTreeDataResult = {
+ files: FileTreeNode[];
+ loading: boolean;
+};
+
+export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ const projectName = selectedProject?.name;
+
+ if (!projectName) {
+ setFiles([]);
+ setLoading(false);
+ return;
+ }
+
+ const abortController = new AbortController();
+ // Track mount state so aborted or late responses do not enqueue stale state updates.
+ let isActive = true;
+
+ const fetchFiles = async () => {
+ if (isActive) {
+ setLoading(true);
+ }
+ try {
+ const response = await api.getFiles(projectName, { signal: abortController.signal });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('File fetch failed:', response.status, errorText);
+ if (isActive) {
+ setFiles([]);
+ }
+ return;
+ }
+
+ const data = (await response.json()) as FileTreeNode[];
+ if (isActive) {
+ setFiles(data);
+ }
+ } catch (error) {
+ if ((error as { name?: string }).name === 'AbortError') {
+ return;
+ }
+
+ console.error('Error fetching files:', error);
+ if (isActive) {
+ setFiles([]);
+ }
+ } finally {
+ if (isActive) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void fetchFiles();
+
+ return () => {
+ isActive = false;
+ abortController.abort();
+ };
+ }, [selectedProject?.name]);
+
+ return {
+ files,
+ loading,
+ };
+}
diff --git a/src/components/file-tree/hooks/useFileTreeSearch.ts b/src/components/file-tree/hooks/useFileTreeSearch.ts
new file mode 100644
index 0000000..388a717
--- /dev/null
+++ b/src/components/file-tree/hooks/useFileTreeSearch.ts
@@ -0,0 +1,42 @@
+import { useEffect, useState } from 'react';
+import { collectExpandedDirectoryPaths, filterFileTree } from '../utils/fileTreeUtils';
+import type { FileTreeNode } from '../types/types';
+
+type UseFileTreeSearchArgs = {
+ files: FileTreeNode[];
+ expandDirectories: (paths: string[]) => void;
+};
+
+type UseFileTreeSearchResult = {
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+ filteredFiles: FileTreeNode[];
+};
+
+export function useFileTreeSearch({
+ files,
+ expandDirectories,
+}: UseFileTreeSearchArgs): UseFileTreeSearchResult {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [filteredFiles, setFilteredFiles] = useState(files);
+
+ useEffect(() => {
+ const query = searchQuery.trim().toLowerCase();
+
+ if (!query) {
+ setFilteredFiles(files);
+ return;
+ }
+
+ const filtered = filterFileTree(files, query);
+ setFilteredFiles(filtered);
+ // Keep search results visible by opening every matching ancestor directory once per query update.
+ expandDirectories(collectExpandedDirectoryPaths(filtered));
+ }, [files, searchQuery, expandDirectories]);
+
+ return {
+ searchQuery,
+ setSearchQuery,
+ filteredFiles,
+ };
+}
diff --git a/src/components/file-tree/hooks/useFileTreeViewMode.ts b/src/components/file-tree/hooks/useFileTreeViewMode.ts
new file mode 100644
index 0000000..a8048a1
--- /dev/null
+++ b/src/components/file-tree/hooks/useFileTreeViewMode.ts
@@ -0,0 +1,43 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ FILE_TREE_DEFAULT_VIEW_MODE,
+ FILE_TREE_VIEW_MODES,
+ FILE_TREE_VIEW_MODE_STORAGE_KEY,
+} from '../constants/constants';
+import type { FileTreeViewMode } from '../types/types';
+
+type UseFileTreeViewModeResult = {
+ viewMode: FileTreeViewMode;
+ changeViewMode: (mode: FileTreeViewMode) => void;
+};
+
+export function useFileTreeViewMode(): UseFileTreeViewModeResult {
+ const [viewMode, setViewMode] = useState(FILE_TREE_DEFAULT_VIEW_MODE);
+
+ useEffect(() => {
+ try {
+ const savedViewMode = localStorage.getItem(FILE_TREE_VIEW_MODE_STORAGE_KEY);
+ if (savedViewMode && FILE_TREE_VIEW_MODES.includes(savedViewMode as FileTreeViewMode)) {
+ setViewMode(savedViewMode as FileTreeViewMode);
+ }
+ } catch {
+ // Keep default view mode when storage is unavailable.
+ }
+ }, []);
+
+ const changeViewMode = useCallback((mode: FileTreeViewMode) => {
+ setViewMode(mode);
+
+ try {
+ localStorage.setItem(FILE_TREE_VIEW_MODE_STORAGE_KEY, mode);
+ } catch {
+ // Keep runtime state even when persistence fails.
+ }
+ }, []);
+
+ return {
+ viewMode,
+ changeViewMode,
+ };
+}
+
diff --git a/src/components/file-tree/types/types.ts b/src/components/file-tree/types/types.ts
new file mode 100644
index 0000000..fb2ac84
--- /dev/null
+++ b/src/components/file-tree/types/types.ts
@@ -0,0 +1,30 @@
+import type { LucideIcon } from 'lucide-react';
+
+export type FileTreeViewMode = 'simple' | 'compact' | 'detailed';
+
+export type FileTreeItemType = 'file' | 'directory';
+
+export interface FileTreeNode {
+ name: string;
+ type: FileTreeItemType;
+ path: string;
+ size?: number;
+ modified?: string;
+ permissionsRwx?: string;
+ children?: FileTreeNode[];
+ [key: string]: unknown;
+}
+
+export interface FileTreeImageSelection {
+ name: string;
+ path: string;
+ projectPath?: string;
+ projectName: string;
+}
+
+export interface FileIconData {
+ icon: LucideIcon;
+ color: string;
+}
+
+export type FileIconMap = Record;
diff --git a/src/components/file-tree/utils/fileTreeUtils.ts b/src/components/file-tree/utils/fileTreeUtils.ts
new file mode 100644
index 0000000..cb268e5
--- /dev/null
+++ b/src/components/file-tree/utils/fileTreeUtils.ts
@@ -0,0 +1,83 @@
+import type { TFunction } from 'i18next';
+import { IMAGE_FILE_EXTENSIONS } from '../constants/constants';
+import type { FileTreeNode } from '../types/types';
+
+export function filterFileTree(items: FileTreeNode[], query: string): FileTreeNode[] {
+ return items.reduce((filteredItems, item) => {
+ const matchesName = item.name.toLowerCase().includes(query);
+ const filteredChildren =
+ item.type === 'directory' && item.children ? filterFileTree(item.children, query) : [];
+
+ if (matchesName || filteredChildren.length > 0) {
+ filteredItems.push({
+ ...item,
+ children: filteredChildren,
+ });
+ }
+
+ return filteredItems;
+ }, []);
+}
+
+// During search we auto-expand every directory present in the filtered subtree.
+export function collectExpandedDirectoryPaths(items: FileTreeNode[]): string[] {
+ const paths: string[] = [];
+
+ const visit = (nodes: FileTreeNode[]) => {
+ nodes.forEach((node) => {
+ if (node.type === 'directory' && node.children && node.children.length > 0) {
+ paths.push(node.path);
+ visit(node.children);
+ }
+ });
+ };
+
+ visit(items);
+ return paths;
+}
+
+export function formatFileSize(bytes?: number): string {
+ if (!bytes || bytes === 0) {
+ return '0 B';
+ }
+
+ const base = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const index = Math.floor(Math.log(bytes) / Math.log(base));
+
+ return `${(bytes / Math.pow(base, index)).toFixed(1).replace(/\.0$/, '')} ${sizes[index]}`;
+}
+
+export function formatRelativeTime(date: string | undefined, t: TFunction): string {
+ if (!date) {
+ return '-';
+ }
+
+ const now = new Date();
+ const past = new Date(date);
+ const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
+
+ if (diffInSeconds < 60) {
+ return t('fileTree.justNow');
+ }
+
+ if (diffInSeconds < 3600) {
+ return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
+ }
+
+ if (diffInSeconds < 86400) {
+ return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
+ }
+
+ if (diffInSeconds < 2592000) {
+ return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
+ }
+
+ return past.toLocaleDateString();
+}
+
+export function isImageFile(filename: string): boolean {
+ const extension = filename.split('.').pop()?.toLowerCase();
+ return Boolean(extension && IMAGE_FILE_EXTENSIONS.has(extension));
+}
+
diff --git a/src/components/file-tree/view/FileTree.tsx b/src/components/file-tree/view/FileTree.tsx
new file mode 100644
index 0000000..5314496
--- /dev/null
+++ b/src/components/file-tree/view/FileTree.tsx
@@ -0,0 +1,103 @@
+import { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { cn } from '../../../lib/utils';
+import ImageViewer from './ImageViewer';
+import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
+import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
+import { useFileTreeData } from '../hooks/useFileTreeData';
+import { useFileTreeSearch } from '../hooks/useFileTreeSearch';
+import { useFileTreeViewMode } from '../hooks/useFileTreeViewMode';
+import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
+import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
+import FileTreeBody from './FileTreeBody';
+import FileTreeDetailedColumns from './FileTreeDetailedColumns';
+import FileTreeHeader from './FileTreeHeader';
+import FileTreeLoadingState from './FileTreeLoadingState';
+import { Project } from '../../../types/app';
+
+type FileTreeProps = {
+ selectedProject: Project | null;
+ onFileOpen?: (filePath: string) => void;
+}
+
+export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) {
+ const { t } = useTranslation();
+ const [selectedImage, setSelectedImage] = useState(null);
+
+ const { files, loading } = useFileTreeData(selectedProject);
+ const { viewMode, changeViewMode } = useFileTreeViewMode();
+ const { expandedDirs, toggleDirectory, expandDirectories } = useExpandedDirectories();
+ const { searchQuery, setSearchQuery, filteredFiles } = useFileTreeSearch({
+ files,
+ expandDirectories,
+ });
+
+ const renderFileIcon = useCallback((filename: string) => {
+ const { icon: Icon, color } = getFileIconData(filename);
+ return ;
+ }, []);
+
+ // Centralized click behavior keeps file actions identical across all presentation modes.
+ const handleItemClick = useCallback(
+ (item: FileTreeNode) => {
+ if (item.type === 'directory') {
+ toggleDirectory(item.path);
+ return;
+ }
+
+ if (isImageFile(item.name) && selectedProject) {
+ setSelectedImage({
+ name: item.name,
+ path: item.path,
+ projectPath: selectedProject.path,
+ projectName: selectedProject.name,
+ });
+ return;
+ }
+
+ onFileOpen?.(item.path);
+ },
+ [onFileOpen, selectedProject, toggleDirectory],
+ );
+
+ const formatRelativeTimeLabel = useCallback(
+ (date?: string) => formatRelativeTime(date, t),
+ [t],
+ );
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
+ {viewMode === 'detailed' && filteredFiles.length > 0 && }
+
+
+
+ {selectedImage && (
+ setSelectedImage(null)}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/file-tree/view/FileTreeBody.tsx b/src/components/file-tree/view/FileTreeBody.tsx
new file mode 100644
index 0000000..dd59585
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeBody.tsx
@@ -0,0 +1,62 @@
+import type { ReactNode } from 'react';
+import { Folder, Search } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { ScrollArea } from '../../ui/scroll-area';
+import type { FileTreeNode, FileTreeViewMode } from '../types/types';
+import FileTreeEmptyState from './FileTreeEmptyState';
+import FileTreeList from './FileTreeList';
+
+type FileTreeBodyProps = {
+ files: FileTreeNode[];
+ filteredFiles: FileTreeNode[];
+ searchQuery: string;
+ viewMode: FileTreeViewMode;
+ expandedDirs: Set;
+ onItemClick: (item: FileTreeNode) => void;
+ renderFileIcon: (filename: string) => ReactNode;
+ formatFileSize: (bytes?: number) => string;
+ formatRelativeTime: (date?: string) => string;
+};
+
+export default function FileTreeBody({
+ files,
+ filteredFiles,
+ searchQuery,
+ viewMode,
+ expandedDirs,
+ onItemClick,
+ renderFileIcon,
+ formatFileSize,
+ formatRelativeTime,
+}: FileTreeBodyProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ {files.length === 0 ? (
+
+ ) : filteredFiles.length === 0 && searchQuery ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeDetailedColumns.tsx b/src/components/file-tree/view/FileTreeDetailedColumns.tsx
new file mode 100644
index 0000000..38b1a00
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeDetailedColumns.tsx
@@ -0,0 +1,17 @@
+import { useTranslation } from 'react-i18next';
+
+export default function FileTreeDetailedColumns() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
{t('fileTree.name')}
+
{t('fileTree.size')}
+
{t('fileTree.modified')}
+
{t('fileTree.permissions')}
+
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeEmptyState.tsx b/src/components/file-tree/view/FileTreeEmptyState.tsx
new file mode 100644
index 0000000..088fb7c
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeEmptyState.tsx
@@ -0,0 +1,20 @@
+import type { LucideIcon } from 'lucide-react';
+
+type FileTreeEmptyStateProps = {
+ icon: LucideIcon;
+ title: string;
+ description: string;
+};
+
+export default function FileTreeEmptyState({ icon: Icon, title, description }: FileTreeEmptyStateProps) {
+ return (
+
+
+
+
+
{title}
+
{description}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeHeader.tsx b/src/components/file-tree/view/FileTreeHeader.tsx
new file mode 100644
index 0000000..04d3124
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeHeader.tsx
@@ -0,0 +1,81 @@
+import { Eye, List, Search, TableProperties, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '../../ui/button';
+import { Input } from '../../ui/input';
+import type { FileTreeViewMode } from '../types/types';
+
+type FileTreeHeaderProps = {
+ viewMode: FileTreeViewMode;
+ onViewModeChange: (mode: FileTreeViewMode) => void;
+ searchQuery: string;
+ onSearchQueryChange: (query: string) => void;
+};
+
+export default function FileTreeHeader({
+ viewMode,
+ onViewModeChange,
+ searchQuery,
+ onSearchQueryChange,
+}: FileTreeHeaderProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
{t('fileTree.files')}
+
+
onViewModeChange('simple')}
+ title={t('fileTree.simpleView')}
+ >
+
+
+
onViewModeChange('compact')}
+ title={t('fileTree.compactView')}
+ >
+
+
+
onViewModeChange('detailed')}
+ title={t('fileTree.detailedView')}
+ >
+
+
+
+
+
+
+
+ onSearchQueryChange(event.target.value)}
+ className="pl-8 pr-8 h-8 text-sm"
+ />
+ {searchQuery && (
+ onSearchQueryChange('')}
+ title={t('fileTree.clearSearch')}
+ >
+
+
+ )}
+
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeList.tsx b/src/components/file-tree/view/FileTreeList.tsx
new file mode 100644
index 0000000..3470ab8
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeList.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from 'react';
+import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
+import FileTreeNode from './FileTreeNode';
+
+type FileTreeListProps = {
+ items: FileTreeNodeType[];
+ viewMode: FileTreeViewMode;
+ expandedDirs: Set;
+ onItemClick: (item: FileTreeNodeType) => void;
+ renderFileIcon: (filename: string) => ReactNode;
+ formatFileSize: (bytes?: number) => string;
+ formatRelativeTime: (date?: string) => string;
+};
+
+export default function FileTreeList({
+ items,
+ viewMode,
+ expandedDirs,
+ onItemClick,
+ renderFileIcon,
+ formatFileSize,
+ formatRelativeTime,
+}: FileTreeListProps) {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeLoadingState.tsx b/src/components/file-tree/view/FileTreeLoadingState.tsx
new file mode 100644
index 0000000..5bc1235
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeLoadingState.tsx
@@ -0,0 +1,12 @@
+import { useTranslation } from 'react-i18next';
+
+export default function FileTreeLoadingState() {
+ const { t } = useTranslation();
+
+ return (
+
+
{t('fileTree.loading')}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeNode.tsx b/src/components/file-tree/view/FileTreeNode.tsx
new file mode 100644
index 0000000..05e43ee
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeNode.tsx
@@ -0,0 +1,141 @@
+import type { ReactNode } from 'react';
+import { ChevronRight, Folder, FolderOpen } from 'lucide-react';
+import { cn } from '../../../lib/utils';
+import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
+
+type FileTreeNodeProps = {
+ item: FileTreeNodeType;
+ level: number;
+ viewMode: FileTreeViewMode;
+ expandedDirs: Set;
+ onItemClick: (item: FileTreeNodeType) => void;
+ renderFileIcon: (filename: string) => ReactNode;
+ formatFileSize: (bytes?: number) => string;
+ formatRelativeTime: (date?: string) => string;
+};
+
+type TreeItemIconProps = {
+ item: FileTreeNodeType;
+ isOpen: boolean;
+ renderFileIcon: (filename: string) => ReactNode;
+};
+
+function TreeItemIcon({ item, isOpen, renderFileIcon }: TreeItemIconProps) {
+ if (item.type === 'directory') {
+ return (
+
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ return {renderFileIcon(item.name)} ;
+}
+
+export default function FileTreeNode({
+ item,
+ level,
+ viewMode,
+ expandedDirs,
+ onItemClick,
+ renderFileIcon,
+ formatFileSize,
+ formatRelativeTime,
+}: FileTreeNodeProps) {
+ const isDirectory = item.type === 'directory';
+ const isOpen = isDirectory && expandedDirs.has(item.path);
+ const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0);
+
+ const nameClassName = cn(
+ 'text-[13px] leading-tight truncate',
+ isDirectory ? 'font-medium text-foreground' : 'text-foreground/90',
+ );
+
+ // View mode only changes the row layout; selection, expansion, and recursion stay shared.
+ const rowClassName = cn(
+ viewMode === 'detailed'
+ ? 'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100'
+ : viewMode === 'compact'
+ ? 'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100'
+ : 'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm hover:bg-accent/60 transition-colors duration-100',
+ isDirectory && isOpen && 'border-l-2 border-primary/30',
+ (isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '',
+ );
+
+ return (
+
+
onItemClick(item)}
+ >
+ {viewMode === 'detailed' ? (
+ <>
+
+
+ {item.name}
+
+
+ {item.type === 'file' ? formatFileSize(item.size) : ''}
+
+
{formatRelativeTime(item.modified)}
+
{item.permissionsRwx || ''}
+ >
+ ) : viewMode === 'compact' ? (
+ <>
+
+
+ {item.name}
+
+
+ {item.type === 'file' && (
+ <>
+ {formatFileSize(item.size)}
+ {item.permissionsRwx}
+ >
+ )}
+
+ >
+ ) : (
+ <>
+
+
{item.name}
+ >
+ )}
+
+
+ {isDirectory && isOpen && hasChildren && (
+
+
+ {item.children?.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/ImageViewer.jsx b/src/components/file-tree/view/ImageViewer.tsx
similarity index 72%
rename from src/components/ImageViewer.jsx
rename to src/components/file-tree/view/ImageViewer.tsx
index bda1458..ce0336c 100644
--- a/src/components/ImageViewer.jsx
+++ b/src/components/file-tree/view/ImageViewer.tsx
@@ -1,16 +1,22 @@
-import React, { useEffect, useState } from 'react';
-import { Button } from './ui/button';
+import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
-import { authenticatedFetch } from '../utils/api';
+import { Button } from '../../ui/button';
+import { authenticatedFetch } from '../../../utils/api';
+import type { FileTreeImageSelection } from '../types/types';
-function ImageViewer({ file, onClose }) {
+type ImageViewerProps = {
+ file: FileTreeImageSelection;
+ onClose: () => void;
+};
+
+export default function ImageViewer({ file, onClose }: ImageViewerProps) {
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
- const [imageUrl, setImageUrl] = useState(null);
- const [error, setError] = useState(null);
+ const [imageUrl, setImageUrl] = useState(null);
+ const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
- let objectUrl;
+ let objectUrl: string | null = null;
const controller = new AbortController();
const loadImage = async () => {
@@ -20,7 +26,7 @@ function ImageViewer({ file, onClose }) {
setImageUrl(null);
const response = await authenticatedFetch(imagePath, {
- signal: controller.signal
+ signal: controller.signal,
});
if (!response.ok) {
@@ -30,11 +36,11 @@ function ImageViewer({ file, onClose }) {
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl);
- } catch (err) {
- if (err.name === 'AbortError') {
+ } catch (loadError: unknown) {
+ if (loadError instanceof Error && loadError.name === 'AbortError') {
return;
}
- console.error('Error loading image:', err);
+ console.error('Error loading image:', loadError);
setError('Unable to load image');
} finally {
setLoading(false);
@@ -55,15 +61,8 @@ function ImageViewer({ file, onClose }) {
-
- {file.name}
-
-
+ {file.name}
+
@@ -71,7 +70,7 @@ function ImageViewer({ file, onClose }) {
{loading && (
-
Loading image…
+
Loading image...
)}
{!loading && imageUrl && (
@@ -90,13 +89,9 @@ function ImageViewer({ file, onClose }) {
-
- {file.path}
-
+
{file.path}
);
}
-
-export default ImageViewer;
diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts
new file mode 100644
index 0000000..5defa41
--- /dev/null
+++ b/src/components/git-panel/constants/constants.ts
@@ -0,0 +1,70 @@
+import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
+
+export const DEFAULT_BRANCH = 'main';
+export const RECENT_COMMITS_LIMIT = 10;
+
+export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [
+ { key: 'modified', status: 'M' },
+ { key: 'added', status: 'A' },
+ { key: 'deleted', status: 'D' },
+ { key: 'untracked', status: 'U' },
+];
+
+export const FILE_STATUS_LABELS: Record = {
+ M: 'Modified',
+ A: 'Added',
+ D: 'Deleted',
+ U: 'Untracked',
+};
+
+export const FILE_STATUS_BADGE_CLASSES: Record = {
+ M: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50',
+ A: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50',
+ D: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50',
+ U: 'bg-muted text-muted-foreground border-border',
+};
+
+export const CONFIRMATION_TITLES: Record = {
+ discard: 'Discard Changes',
+ delete: 'Delete File',
+ commit: 'Confirm Commit',
+ pull: 'Confirm Pull',
+ push: 'Confirm Push',
+ publish: 'Publish Branch',
+};
+
+export const CONFIRMATION_ACTION_LABELS: Record = {
+ discard: 'Discard',
+ delete: 'Delete',
+ commit: 'Commit',
+ pull: 'Pull',
+ push: 'Push',
+ publish: 'Publish',
+};
+
+export const CONFIRMATION_BUTTON_CLASSES: Record = {
+ discard: 'bg-red-600 hover:bg-red-700',
+ delete: 'bg-red-600 hover:bg-red-700',
+ commit: 'bg-primary hover:bg-primary/90',
+ pull: 'bg-green-600 hover:bg-green-700',
+ push: 'bg-orange-600 hover:bg-orange-700',
+ publish: 'bg-purple-600 hover:bg-purple-700',
+};
+
+export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = {
+ discard: 'bg-red-100 dark:bg-red-900/30',
+ delete: 'bg-red-100 dark:bg-red-900/30',
+ commit: 'bg-yellow-100 dark:bg-yellow-900/30',
+ pull: 'bg-yellow-100 dark:bg-yellow-900/30',
+ push: 'bg-yellow-100 dark:bg-yellow-900/30',
+ publish: 'bg-yellow-100 dark:bg-yellow-900/30',
+};
+
+export const CONFIRMATION_ICON_CLASSES: Record = {
+ discard: 'text-red-600 dark:text-red-400',
+ delete: 'text-red-600 dark:text-red-400',
+ commit: 'text-yellow-600 dark:text-yellow-400',
+ pull: 'text-yellow-600 dark:text-yellow-400',
+ push: 'text-yellow-600 dark:text-yellow-400',
+ publish: 'text-yellow-600 dark:text-yellow-400',
+};
diff --git a/src/components/git-panel/hooks/useGitPanelController.ts b/src/components/git-panel/hooks/useGitPanelController.ts
new file mode 100644
index 0000000..3686867
--- /dev/null
+++ b/src/components/git-panel/hooks/useGitPanelController.ts
@@ -0,0 +1,710 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { authenticatedFetch } from '../../../utils/api';
+import { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants';
+import type {
+ GitApiErrorResponse,
+ GitBranchesResponse,
+ GitCommitSummary,
+ GitCommitsResponse,
+ GitDiffMap,
+ GitDiffResponse,
+ GitFileWithDiffResponse,
+ GitGenerateMessageResponse,
+ GitOperationResponse,
+ GitPanelController,
+ GitRemoteStatus,
+ GitStatusResponse,
+ UseGitPanelControllerOptions,
+} from '../types/types';
+import { getAllChangedFiles } from '../utils/gitPanelUtils';
+import { useSelectedProvider } from './useSelectedProvider';
+
+// ! use authenticatedFetch directly. fetchWithAuth is redundant
+const fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise;
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof DOMException && error.name === 'AbortError';
+}
+
+async function readJson(response: Response, signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ throw new DOMException('Request aborted', 'AbortError');
+ }
+
+ const data = (await response.json()) as T;
+
+ if (signal?.aborted) {
+ throw new DOMException('Request aborted', 'AbortError');
+ }
+
+ return data;
+}
+
+export function useGitPanelController({
+ selectedProject,
+ activeView,
+ onFileOpen,
+}: UseGitPanelControllerOptions): GitPanelController {
+ const [gitStatus, setGitStatus] = useState(null);
+ const [gitDiff, setGitDiff] = useState({});
+ const [isLoading, setIsLoading] = useState(false);
+ const [currentBranch, setCurrentBranch] = useState('');
+ const [branches, setBranches] = useState([]);
+ const [recentCommits, setRecentCommits] = useState([]);
+ const [commitDiffs, setCommitDiffs] = useState({});
+ const [remoteStatus, setRemoteStatus] = useState(null);
+ const [isCreatingBranch, setIsCreatingBranch] = useState(false);
+ const [isFetching, setIsFetching] = useState(false);
+ const [isPulling, setIsPulling] = useState(false);
+ const [isPushing, setIsPushing] = useState(false);
+ const [isPublishing, setIsPublishing] = useState(false);
+ const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
+ const selectedProjectNameRef = useRef(selectedProject?.name ?? null);
+
+ useEffect(() => {
+ selectedProjectNameRef.current = selectedProject?.name ?? null;
+ }, [selectedProject]);
+
+ const provider = useSelectedProvider();
+
+ const fetchFileDiff = useCallback(
+ async (filePath: string, signal?: AbortSignal) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ const projectName = selectedProject.name;
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/diff?project=${encodeURIComponent(projectName)}&file=${encodeURIComponent(filePath)}`,
+ { signal },
+ );
+ const data = await readJson(response, signal);
+
+ if (
+ signal?.aborted ||
+ selectedProjectNameRef.current !== projectName
+ ) {
+ return;
+ }
+
+ if (!data.error && data.diff) {
+ setGitDiff((previous) => ({
+ ...previous,
+ [filePath]: data.diff as string,
+ }));
+ }
+ } catch (error) {
+ if (signal?.aborted || isAbortError(error)) {
+ return;
+ }
+
+ console.error('Error fetching file diff:', error);
+ }
+ },
+ [selectedProject],
+ );
+
+ const fetchGitStatus = useCallback(async (signal?: AbortSignal) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ const projectName = selectedProject.name;
+
+ setIsLoading(true);
+ try {
+ const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectName)}`, { signal });
+ const data = await readJson(response, signal);
+
+ if (
+ signal?.aborted ||
+ selectedProjectNameRef.current !== projectName
+ ) {
+ return;
+ }
+
+ if (data.error) {
+ console.error('Git status error:', data.error);
+ setGitStatus({ error: data.error, details: data.details });
+ setCurrentBranch('');
+ return;
+ }
+
+ setGitStatus(data);
+ setCurrentBranch(data.branch || DEFAULT_BRANCH);
+
+ const changedFiles = getAllChangedFiles(data);
+ changedFiles.forEach((filePath) => {
+ void fetchFileDiff(filePath, signal);
+ });
+ } catch (error) {
+ if (signal?.aborted || isAbortError(error)) {
+ return;
+ }
+
+ if (
+ selectedProjectNameRef.current !== projectName
+ ) {
+ return;
+ }
+
+ console.error('Error fetching git status:', error);
+ setGitStatus({ error: 'Git operation failed', details: String(error) });
+ setCurrentBranch('');
+ } finally {
+ if (
+ signal?.aborted ||
+ selectedProjectNameRef.current !== projectName
+ ) {
+ return;
+ }
+
+ setIsLoading(false);
+ }
+ }, [fetchFileDiff, selectedProject]);
+
+ const fetchBranches = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
+ const data = await readJson(response);
+
+ if (!data.error && data.branches) {
+ setBranches(data.branches);
+ return;
+ }
+
+ setBranches([]);
+ } catch (error) {
+ console.error('Error fetching branches:', error);
+ setBranches([]);
+ }
+ }, [selectedProject]);
+
+ const fetchRemoteStatus = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
+ const data = await readJson(response);
+
+ if (!data.error) {
+ setRemoteStatus(data as GitRemoteStatus);
+ return;
+ }
+
+ setRemoteStatus(null);
+ } catch (error) {
+ console.error('Error fetching remote status:', error);
+ setRemoteStatus(null);
+ }
+ }, [selectedProject]);
+
+ const switchBranch = useCallback(
+ async (branchName: string) => {
+ if (!selectedProject) {
+ return false;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ branch: branchName,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (!data.success) {
+ console.error('Failed to switch branch:', data.error);
+ return false;
+ }
+
+ setCurrentBranch(branchName);
+ void fetchGitStatus();
+ return true;
+ } catch (error) {
+ console.error('Error switching branch:', error);
+ return false;
+ }
+ },
+ [fetchGitStatus, selectedProject],
+ );
+
+ const createBranch = useCallback(
+ async (branchName: string) => {
+ const trimmedBranchName = branchName.trim();
+ if (!selectedProject || !trimmedBranchName) {
+ return false;
+ }
+
+ setIsCreatingBranch(true);
+ try {
+ const response = await fetchWithAuth('/api/git/create-branch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ branch: trimmedBranchName,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (!data.success) {
+ console.error('Failed to create branch:', data.error);
+ return false;
+ }
+
+ setCurrentBranch(trimmedBranchName);
+ void fetchBranches();
+ void fetchGitStatus();
+ return true;
+ } catch (error) {
+ console.error('Error creating branch:', error);
+ return false;
+ } finally {
+ setIsCreatingBranch(false);
+ }
+ },
+ [fetchBranches, fetchGitStatus, selectedProject],
+ );
+
+ const handleFetch = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsFetching(true);
+ try {
+ const response = await fetchWithAuth('/api/git/fetch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Fetch failed:', data.error);
+ } catch (error) {
+ console.error('Error fetching from remote:', error);
+ } finally {
+ setIsFetching(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const handlePull = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsPulling(true);
+ try {
+ const response = await fetchWithAuth('/api/git/pull', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Pull failed:', data.error);
+ } catch (error) {
+ console.error('Error pulling from remote:', error);
+ } finally {
+ setIsPulling(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const handlePush = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsPushing(true);
+ try {
+ const response = await fetchWithAuth('/api/git/push', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Push failed:', data.error);
+ } catch (error) {
+ console.error('Error pushing to remote:', error);
+ } finally {
+ setIsPushing(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const handlePublish = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ setIsPublishing(true);
+ try {
+ const response = await fetchWithAuth('/api/git/publish', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ branch: currentBranch,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return;
+ }
+
+ console.error('Publish failed:', data.error);
+ } catch (error) {
+ console.error('Error publishing branch:', error);
+ } finally {
+ setIsPublishing(false);
+ }
+ }, [currentBranch, fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const discardChanges = useCallback(
+ async (filePath: string) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/discard', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ file: filePath,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ return;
+ }
+
+ console.error('Discard failed:', data.error);
+ } catch (error) {
+ console.error('Error discarding changes:', error);
+ }
+ },
+ [fetchGitStatus, selectedProject],
+ );
+
+ const deleteUntrackedFile = useCallback(
+ async (filePath: string) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/delete-untracked', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ file: filePath,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ return;
+ }
+
+ console.error('Delete failed:', data.error);
+ } catch (error) {
+ console.error('Error deleting untracked file:', error);
+ }
+ },
+ [fetchGitStatus, selectedProject],
+ );
+
+ const fetchRecentCommits = useCallback(async () => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=${RECENT_COMMITS_LIMIT}`,
+ );
+ const data = await readJson(response);
+
+ if (!data.error && data.commits) {
+ setRecentCommits(data.commits);
+ }
+ } catch (error) {
+ console.error('Error fetching commits:', error);
+ }
+ }, [selectedProject]);
+
+ const fetchCommitDiff = useCallback(
+ async (commitHash: string) => {
+ if (!selectedProject) {
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`,
+ );
+ const data = await readJson(response);
+
+ if (!data.error && data.diff) {
+ setCommitDiffs((previous) => ({
+ ...previous,
+ [commitHash]: data.diff as string,
+ }));
+ }
+ } catch (error) {
+ console.error('Error fetching commit diff:', error);
+ }
+ },
+ [selectedProject],
+ );
+
+ const generateCommitMessage = useCallback(
+ async (files: string[]) => {
+ if (!selectedProject || files.length === 0) {
+ return null;
+ }
+
+ try {
+ const response = await authenticatedFetch('/api/git/generate-commit-message', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ files,
+ provider,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.message) {
+ return data.message;
+ }
+
+ console.error('Failed to generate commit message:', data.error);
+ return null;
+ } catch (error) {
+ console.error('Error generating commit message:', error);
+ return null;
+ }
+ },
+ [provider, selectedProject],
+ );
+
+ const commitChanges = useCallback(
+ async (message: string, files: string[]) => {
+ if (!selectedProject || !message.trim() || files.length === 0) {
+ return false;
+ }
+
+ try {
+ const response = await fetchWithAuth('/api/git/commit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ message,
+ files,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return true;
+ }
+
+ console.error('Commit failed:', data.error);
+ return false;
+ } catch (error) {
+ console.error('Error committing changes:', error);
+ return false;
+ }
+ },
+ [fetchGitStatus, fetchRemoteStatus, selectedProject],
+ );
+
+ const createInitialCommit = useCallback(async () => {
+ if (!selectedProject) {
+ throw new Error('No project selected');
+ }
+
+ setIsCreatingInitialCommit(true);
+ try {
+ const response = await fetchWithAuth('/api/git/initial-commit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project: selectedProject.name,
+ }),
+ });
+
+ const data = await readJson(response);
+ if (data.success) {
+ void fetchGitStatus();
+ void fetchRemoteStatus();
+ return true;
+ }
+
+ throw new Error(data.error || 'Failed to create initial commit');
+ } catch (error) {
+ console.error('Error creating initial commit:', error);
+ throw error;
+ } finally {
+ setIsCreatingInitialCommit(false);
+ }
+ }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ const openFile = useCallback(
+ async (filePath: string) => {
+ if (!onFileOpen) {
+ return;
+ }
+
+ if (!selectedProject) {
+ onFileOpen(filePath);
+ return;
+ }
+
+ try {
+ const response = await fetchWithAuth(
+ `/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,
+ );
+ const data = await readJson(response);
+
+ if (data.error) {
+ console.error('Error fetching file with diff:', data.error);
+ onFileOpen(filePath);
+ return;
+ }
+
+ onFileOpen(filePath, {
+ old_string: data.oldContent || '',
+ new_string: data.currentContent || '',
+ });
+ } catch (error) {
+ console.error('Error opening file:', error);
+ onFileOpen(filePath);
+ }
+ },
+ [onFileOpen, selectedProject],
+ );
+
+ const refreshAll = useCallback(() => {
+ void fetchGitStatus();
+ void fetchBranches();
+ void fetchRemoteStatus();
+ }, [fetchBranches, fetchGitStatus, fetchRemoteStatus]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ // Reset repository-scoped state when project changes to avoid stale UI.
+ setCurrentBranch('');
+ setBranches([]);
+ setGitStatus(null);
+ setRemoteStatus(null);
+ setGitDiff({});
+ setRecentCommits([]);
+ setCommitDiffs({});
+ setIsLoading(false);
+
+ if (!selectedProject) {
+ return () => {
+ controller.abort();
+ };
+ }
+
+ void fetchGitStatus(controller.signal);
+ void fetchBranches();
+ void fetchRemoteStatus();
+
+ return () => {
+ controller.abort();
+ };
+ }, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
+
+ useEffect(() => {
+ if (!selectedProject || activeView !== 'history') {
+ return;
+ }
+
+ void fetchRecentCommits();
+ }, [activeView, fetchRecentCommits, selectedProject]);
+
+ return {
+ gitStatus,
+ gitDiff,
+ isLoading,
+ currentBranch,
+ branches,
+ recentCommits,
+ commitDiffs,
+ remoteStatus,
+ isCreatingBranch,
+ isFetching,
+ isPulling,
+ isPushing,
+ isPublishing,
+ isCreatingInitialCommit,
+ refreshAll,
+ switchBranch,
+ createBranch,
+ handleFetch,
+ handlePull,
+ handlePush,
+ handlePublish,
+ discardChanges,
+ deleteUntrackedFile,
+ fetchCommitDiff,
+ generateCommitMessage,
+ commitChanges,
+ createInitialCommit,
+ openFile,
+ };
+}
diff --git a/src/components/git-panel/hooks/useSelectedProvider.ts b/src/components/git-panel/hooks/useSelectedProvider.ts
new file mode 100644
index 0000000..543a54d
--- /dev/null
+++ b/src/components/git-panel/hooks/useSelectedProvider.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react';
+
+export function useSelectedProvider() {
+ const [provider, setProvider] = useState(() => {
+ return localStorage.getItem('selected-provider') || 'claude';
+ });
+
+ useEffect(() => {
+ // Keep provider in sync when another tab changes the selected provider.
+ const handleStorageChange = () => {
+ const nextProvider = localStorage.getItem('selected-provider') || 'claude';
+ setProvider(nextProvider);
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+ return () => window.removeEventListener('storage', handleStorageChange);
+ }, []);
+
+ return provider;
+}
diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts
new file mode 100644
index 0000000..452f779
--- /dev/null
+++ b/src/components/git-panel/types/types.ts
@@ -0,0 +1,135 @@
+import type { Project } from '../../../types/app';
+
+export type GitPanelView = 'changes' | 'history';
+export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
+export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
+export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
+
+export type FileDiffInfo = {
+ old_string: string;
+ new_string: string;
+};
+
+export type FileOpenHandler = (filePath: string, diffInfo?: FileDiffInfo) => void;
+
+export type GitPanelProps = {
+ selectedProject: Project | null;
+ isMobile?: boolean;
+ onFileOpen?: FileOpenHandler;
+};
+
+export type GitStatusResponse = {
+ branch?: string;
+ hasCommits?: boolean;
+ modified?: string[];
+ added?: string[];
+ deleted?: string[];
+ untracked?: string[];
+ error?: string;
+ details?: string;
+};
+
+export type GitRemoteStatus = {
+ hasRemote?: boolean;
+ hasUpstream?: boolean;
+ branch?: string;
+ remoteBranch?: string;
+ remoteName?: string | null;
+ ahead?: number;
+ behind?: number;
+ isUpToDate?: boolean;
+ message?: string;
+ error?: string;
+};
+
+export type GitCommitSummary = {
+ hash: string;
+ author: string;
+ email?: string;
+ date: string;
+ message: string;
+ stats?: string;
+};
+
+export type GitDiffMap = Record;
+
+export type GitStatusGroupEntry = {
+ key: GitStatusFileGroup;
+ status: FileStatusCode;
+};
+
+export type ConfirmationRequest = {
+ type: ConfirmActionType;
+ message: string;
+ onConfirm: () => Promise | void;
+};
+
+export type UseGitPanelControllerOptions = {
+ selectedProject: Project | null;
+ activeView: GitPanelView;
+ onFileOpen?: FileOpenHandler;
+};
+
+export type GitPanelController = {
+ gitStatus: GitStatusResponse | null;
+ gitDiff: GitDiffMap;
+ isLoading: boolean;
+ currentBranch: string;
+ branches: string[];
+ recentCommits: GitCommitSummary[];
+ commitDiffs: GitDiffMap;
+ remoteStatus: GitRemoteStatus | null;
+ isCreatingBranch: boolean;
+ isFetching: boolean;
+ isPulling: boolean;
+ isPushing: boolean;
+ isPublishing: boolean;
+ isCreatingInitialCommit: boolean;
+ refreshAll: () => void;
+ switchBranch: (branchName: string) => Promise;
+ createBranch: (branchName: string) => Promise;
+ handleFetch: () => Promise;
+ handlePull: () => Promise;
+ handlePush: () => Promise;
+ handlePublish: () => Promise;
+ discardChanges: (filePath: string) => Promise;
+ deleteUntrackedFile: (filePath: string) => Promise;
+ fetchCommitDiff: (commitHash: string) => Promise;
+ generateCommitMessage: (files: string[]) => Promise;
+ commitChanges: (message: string, files: string[]) => Promise;
+ createInitialCommit: () => Promise;
+ openFile: (filePath: string) => Promise;
+};
+
+export type GitApiErrorResponse = {
+ error?: string;
+ details?: string;
+};
+
+export type GitDiffResponse = GitApiErrorResponse & {
+ diff?: string;
+};
+
+export type GitBranchesResponse = GitApiErrorResponse & {
+ branches?: string[];
+};
+
+export type GitCommitsResponse = GitApiErrorResponse & {
+ commits?: GitCommitSummary[];
+};
+
+export type GitOperationResponse = GitApiErrorResponse & {
+ success?: boolean;
+ output?: string;
+};
+
+export type GitGenerateMessageResponse = GitApiErrorResponse & {
+ message?: string;
+};
+
+export type GitFileWithDiffResponse = GitApiErrorResponse & {
+ oldContent?: string;
+ currentContent?: string;
+ isDeleted?: boolean;
+ isUntracked?: boolean;
+};
diff --git a/src/components/git-panel/utils/gitPanelUtils.ts b/src/components/git-panel/utils/gitPanelUtils.ts
new file mode 100644
index 0000000..736deeb
--- /dev/null
+++ b/src/components/git-panel/utils/gitPanelUtils.ts
@@ -0,0 +1,26 @@
+import { FILE_STATUS_BADGE_CLASSES, FILE_STATUS_GROUPS, FILE_STATUS_LABELS } from '../constants/constants';
+import type { FileStatusCode, GitStatusResponse } from '../types/types';
+
+export function getAllChangedFiles(gitStatus: GitStatusResponse | null): string[] {
+ if (!gitStatus) {
+ return [];
+ }
+
+ return FILE_STATUS_GROUPS.flatMap(({ key }) => gitStatus[key] || []);
+}
+
+export function getChangedFileCount(gitStatus: GitStatusResponse | null): number {
+ return getAllChangedFiles(gitStatus).length;
+}
+
+export function hasChangedFiles(gitStatus: GitStatusResponse | null): boolean {
+ return getChangedFileCount(gitStatus) > 0;
+}
+
+export function getStatusLabel(status: FileStatusCode): string {
+ return FILE_STATUS_LABELS[status] || status;
+}
+
+export function getStatusBadgeClass(status: FileStatusCode): string {
+ return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U;
+}
diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx
new file mode 100644
index 0000000..3560b12
--- /dev/null
+++ b/src/components/git-panel/view/GitPanel.tsx
@@ -0,0 +1,150 @@
+import { useCallback, useState } from 'react';
+import { useGitPanelController } from '../hooks/useGitPanelController';
+import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
+import ChangesView from '../view/changes/ChangesView';
+import HistoryView from '../view/history/HistoryView';
+import GitPanelHeader from '../view/GitPanelHeader';
+import GitRepositoryErrorState from '../view/GitRepositoryErrorState';
+import GitViewTabs from '../view/GitViewTabs';
+import ConfirmActionModal from '../view/modals/ConfirmActionModal';
+
+export default function GitPanel({ selectedProject, isMobile = false, onFileOpen }: GitPanelProps) {
+ const [activeView, setActiveView] = useState('changes');
+ const [wrapText, setWrapText] = useState(true);
+ const [hasExpandedFiles, setHasExpandedFiles] = useState(false);
+ const [confirmAction, setConfirmAction] = useState(null);
+
+ const {
+ gitStatus,
+ gitDiff,
+ isLoading,
+ currentBranch,
+ branches,
+ recentCommits,
+ commitDiffs,
+ remoteStatus,
+ isCreatingBranch,
+ isFetching,
+ isPulling,
+ isPushing,
+ isPublishing,
+ isCreatingInitialCommit,
+ refreshAll,
+ switchBranch,
+ createBranch,
+ handleFetch,
+ handlePull,
+ handlePush,
+ handlePublish,
+ discardChanges,
+ deleteUntrackedFile,
+ fetchCommitDiff,
+ generateCommitMessage,
+ commitChanges,
+ createInitialCommit,
+ openFile,
+ } = useGitPanelController({
+ selectedProject,
+ activeView,
+ onFileOpen,
+ });
+
+ const executeConfirmedAction = useCallback(async () => {
+ if (!confirmAction) {
+ return;
+ }
+
+ const actionToExecute = confirmAction;
+ setConfirmAction(null);
+
+ try {
+ await actionToExecute.onConfirm();
+ } catch (error) {
+ console.error('Error executing confirmation action:', error);
+ }
+ }, [confirmAction]);
+
+ if (!selectedProject) {
+ return (
+
+
Select a project to view source control
+
+ );
+ }
+
+ return (
+
+
+
+ {gitStatus?.error ? (
+
+ ) : (
+ <>
+
+
+ {activeView === 'changes' && (
+
+ )}
+
+ {activeView === 'history' && (
+
+ )}
+ >
+ )}
+
+ setConfirmAction(null)}
+ onConfirm={() => {
+ void executeConfirmedAction();
+ }}
+ />
+
+ );
+}
diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx
new file mode 100644
index 0000000..739ebba
--- /dev/null
+++ b/src/components/git-panel/view/GitPanelHeader.tsx
@@ -0,0 +1,263 @@
+import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
+import NewBranchModal from './modals/NewBranchModal';
+
+type GitPanelHeaderProps = {
+ isMobile: boolean;
+ currentBranch: string;
+ branches: string[];
+ remoteStatus: GitRemoteStatus | null;
+ isLoading: boolean;
+ isCreatingBranch: boolean;
+ isFetching: boolean;
+ isPulling: boolean;
+ isPushing: boolean;
+ isPublishing: boolean;
+ onRefresh: () => void;
+ onSwitchBranch: (branchName: string) => Promise;
+ onCreateBranch: (branchName: string) => Promise;
+ onFetch: () => Promise;
+ onPull: () => Promise;
+ onPush: () => Promise;
+ onPublish: () => Promise;
+ onRequestConfirmation: (request: ConfirmationRequest) => void;
+};
+
+export default function GitPanelHeader({
+ isMobile,
+ currentBranch,
+ branches,
+ remoteStatus,
+ isLoading,
+ isCreatingBranch,
+ isFetching,
+ isPulling,
+ isPushing,
+ isPublishing,
+ onRefresh,
+ onSwitchBranch,
+ onCreateBranch,
+ onFetch,
+ onPull,
+ onPush,
+ onPublish,
+ onRequestConfirmation,
+}: GitPanelHeaderProps) {
+ const [showBranchDropdown, setShowBranchDropdown] = useState(false);
+ const [showNewBranchModal, setShowNewBranchModal] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setShowBranchDropdown(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const aheadCount = remoteStatus?.ahead || 0;
+ const behindCount = remoteStatus?.behind || 0;
+ const remoteName = remoteStatus?.remoteName || 'remote';
+ const shouldShowFetchButton = aheadCount > 0 && behindCount > 0;
+
+ const requestPullConfirmation = () => {
+ onRequestConfirmation({
+ type: 'pull',
+ message: `Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}?`,
+ onConfirm: onPull,
+ });
+ };
+
+ const requestPushConfirmation = () => {
+ onRequestConfirmation({
+ type: 'push',
+ message: `Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}?`,
+ onConfirm: onPush,
+ });
+ };
+
+ const requestPublishConfirmation = () => {
+ onRequestConfirmation({
+ type: 'publish',
+ message: `Publish branch "${currentBranch}" to ${remoteName}?`,
+ onConfirm: onPublish,
+ });
+ };
+
+ const handleSwitchBranch = async (branchName: string) => {
+ try {
+ const success = await onSwitchBranch(branchName);
+ if (success) {
+ setShowBranchDropdown(false);
+ }
+ } catch (error) {
+ console.error('[GitPanelHeader] Failed to switch branch:', error);
+ }
+ };
+
+ const handleFetch = async () => {
+ try {
+ await onFetch();
+ } catch (error) {
+ console.error('[GitPanelHeader] Failed to fetch remote changes:', error);
+ }
+ };
+
+ return (
+ <>
+
+
+
setShowBranchDropdown((previous) => !previous)}
+ className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
+ >
+
+
+ {currentBranch}
+ {remoteStatus?.hasRemote && (
+
+ {aheadCount > 0 && (
+
+ {'\u2191'}
+ {aheadCount}
+
+ )}
+ {behindCount > 0 && (
+
+ {'\u2193'}
+ {behindCount}
+
+ )}
+ {remoteStatus.isUpToDate && (
+
+ {'\u2713'}
+
+ )}
+
+ )}
+
+
+
+
+ {showBranchDropdown && (
+
+
+ {branches.map((branch) => (
+ void handleSwitchBranch(branch)}
+ className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
+ branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
+ }`}
+ >
+
+ {branch === currentBranch && }
+ {branch}
+
+
+ ))}
+
+
+
{
+ setShowNewBranchModal(true);
+ setShowBranchDropdown(false);
+ }}
+ className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
+ >
+
+ Create new branch
+
+
+
+ )}
+
+
+
+ {remoteStatus?.hasRemote && (
+ <>
+ {!remoteStatus.hasUpstream && (
+
+
+ {isPublishing ? 'Publishing...' : 'Publish'}
+
+ )}
+
+ {remoteStatus.hasUpstream && !remoteStatus.isUpToDate && (
+ <>
+ {behindCount > 0 && (
+
+
+ {isPulling ? 'Pulling...' : `Pull ${behindCount}`}
+
+ )}
+
+ {aheadCount > 0 && (
+
+
+ {isPushing ? 'Pushing...' : `Push ${aheadCount}`}
+
+ )}
+
+ {shouldShowFetchButton && (
+ void handleFetch()}
+ disabled={isFetching}
+ className="px-2.5 py-1 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors"
+ title={`Fetch from ${remoteName}`}
+ >
+
+ {isFetching ? 'Fetching...' : 'Fetch'}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+ setShowNewBranchModal(false)}
+ onCreateBranch={onCreateBranch}
+ />
+ >
+ );
+}
diff --git a/src/components/git-panel/view/GitRepositoryErrorState.tsx b/src/components/git-panel/view/GitRepositoryErrorState.tsx
new file mode 100644
index 0000000..be1e22d
--- /dev/null
+++ b/src/components/git-panel/view/GitRepositoryErrorState.tsx
@@ -0,0 +1,27 @@
+import { GitBranch } from 'lucide-react';
+
+type GitRepositoryErrorStateProps = {
+ error: string;
+ details?: string;
+};
+
+export default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) {
+ return (
+
+
+
+
+
{error}
+ {details && (
+
{details}
+ )}
+
+
+ Tip: Run{' '}
+ git init{' '}
+ in your project directory to initialize git source control.
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/GitViewTabs.tsx b/src/components/git-panel/view/GitViewTabs.tsx
new file mode 100644
index 0000000..004f750
--- /dev/null
+++ b/src/components/git-panel/view/GitViewTabs.tsx
@@ -0,0 +1,45 @@
+import { FileText, History } from 'lucide-react';
+import type { GitPanelView } from '../types/types';
+
+type GitViewTabsProps = {
+ activeView: GitPanelView;
+ isHidden: boolean;
+ onChange: (view: GitPanelView) => void;
+};
+
+export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewTabsProps) {
+ return (
+
+ onChange('changes')}
+ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
+ activeView === 'changes'
+ ? 'text-primary border-b-2 border-primary'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+
+
+ Changes
+
+
+ onChange('history')}
+ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
+ activeView === 'history'
+ ? 'text-primary border-b-2 border-primary'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+
+
+ History
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/ChangesView.tsx b/src/components/git-panel/view/changes/ChangesView.tsx
new file mode 100644
index 0000000..807215a
--- /dev/null
+++ b/src/components/git-panel/view/changes/ChangesView.tsx
@@ -0,0 +1,213 @@
+import { GitBranch, GitCommit, RefreshCw } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
+import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils';
+import CommitComposer from './CommitComposer';
+import FileChangeList from './FileChangeList';
+import FileSelectionControls from './FileSelectionControls';
+import FileStatusLegend from './FileStatusLegend';
+
+type ChangesViewProps = {
+ isMobile: boolean;
+ gitStatus: GitStatusResponse | null;
+ gitDiff: GitDiffMap;
+ isLoading: boolean;
+ wrapText: boolean;
+ isCreatingInitialCommit: boolean;
+ onWrapTextChange: (wrapText: boolean) => void;
+ onCreateInitialCommit: () => Promise;
+ onOpenFile: (filePath: string) => Promise;
+ onDiscardFile: (filePath: string) => Promise;
+ onDeleteFile: (filePath: string) => Promise;
+ onCommitChanges: (message: string, files: string[]) => Promise;
+ onGenerateCommitMessage: (files: string[]) => Promise;
+ onRequestConfirmation: (request: ConfirmationRequest) => void;
+ onExpandedFilesChange: (hasExpandedFiles: boolean) => void;
+};
+
+export default function ChangesView({
+ isMobile,
+ gitStatus,
+ gitDiff,
+ isLoading,
+ wrapText,
+ isCreatingInitialCommit,
+ onWrapTextChange,
+ onCreateInitialCommit,
+ onOpenFile,
+ onDiscardFile,
+ onDeleteFile,
+ onCommitChanges,
+ onGenerateCommitMessage,
+ onRequestConfirmation,
+ onExpandedFilesChange,
+}: ChangesViewProps) {
+ const [expandedFiles, setExpandedFiles] = useState>(new Set());
+ const [selectedFiles, setSelectedFiles] = useState>(new Set());
+
+ const changedFiles = useMemo(() => getAllChangedFiles(gitStatus), [gitStatus]);
+ const hasExpandedFiles = expandedFiles.size > 0;
+
+ useEffect(() => {
+ if (!gitStatus || gitStatus.error) {
+ setSelectedFiles(new Set());
+ return;
+ }
+
+ // Preserve previous behavior: every fresh status snapshot reselects changed files.
+ setSelectedFiles(new Set(getAllChangedFiles(gitStatus)));
+ }, [gitStatus]);
+
+ useEffect(() => {
+ onExpandedFilesChange(hasExpandedFiles);
+ }, [hasExpandedFiles, onExpandedFilesChange]);
+
+ useEffect(() => {
+ return () => {
+ onExpandedFilesChange(false);
+ };
+ }, [onExpandedFilesChange]);
+
+ const toggleFileExpanded = useCallback((filePath: string) => {
+ setExpandedFiles((previous) => {
+ const next = new Set(previous);
+ if (next.has(filePath)) {
+ next.delete(filePath);
+ } else {
+ next.add(filePath);
+ }
+ return next;
+ });
+ }, []);
+
+ const toggleFileSelected = useCallback((filePath: string) => {
+ setSelectedFiles((previous) => {
+ const next = new Set(previous);
+ if (next.has(filePath)) {
+ next.delete(filePath);
+ } else {
+ next.add(filePath);
+ }
+ return next;
+ });
+ }, []);
+
+ const requestFileAction = useCallback(
+ (filePath: string, status: FileStatusCode) => {
+ if (status === 'U') {
+ onRequestConfirmation({
+ type: 'delete',
+ message: `Delete untracked file "${filePath}"? This action cannot be undone.`,
+ onConfirm: async () => {
+ await onDeleteFile(filePath);
+ },
+ });
+ return;
+ }
+
+ onRequestConfirmation({
+ type: 'discard',
+ message: `Discard all changes to "${filePath}"? This action cannot be undone.`,
+ onConfirm: async () => {
+ await onDiscardFile(filePath);
+ },
+ });
+ },
+ [onDeleteFile, onDiscardFile, onRequestConfirmation],
+ );
+
+ const commitSelectedFiles = useCallback(
+ (message: string) => {
+ return onCommitChanges(message, Array.from(selectedFiles));
+ },
+ [onCommitChanges, selectedFiles],
+ );
+
+ const generateMessageForSelection = useCallback(() => {
+ return onGenerateCommitMessage(Array.from(selectedFiles));
+ }, [onGenerateCommitMessage, selectedFiles]);
+
+ return (
+ <>
+
+
+ {gitStatus && !gitStatus.error && (
+ setSelectedFiles(new Set(changedFiles))}
+ onDeselectAll={() => setSelectedFiles(new Set())}
+ />
+ )}
+
+ {!gitStatus?.error && }
+
+
+ {isLoading ? (
+
+
+
+ ) : gitStatus?.hasCommits === false ? (
+
+
+
+
+
No commits yet
+
+ This repository doesn't have any commits yet. Create your first commit to start tracking changes.
+
+
void onCreateInitialCommit()}
+ disabled={isCreatingInitialCommit}
+ className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
+ >
+ {isCreatingInitialCommit ? (
+ <>
+
+ Creating Initial Commit...
+ >
+ ) : (
+ <>
+
+ Create Initial Commit
+ >
+ )}
+
+
+ ) : !gitStatus || !hasChangedFiles(gitStatus) ? (
+
+
+
No changes detected
+
+ ) : (
+
+ {
+ void onOpenFile(filePath);
+ }}
+ onToggleWrapText={() => onWrapTextChange(!wrapText)}
+ onRequestFileAction={requestFileAction}
+ />
+
+ )}
+
+ >
+ );
+}
diff --git a/src/components/git-panel/view/changes/CommitComposer.tsx b/src/components/git-panel/view/changes/CommitComposer.tsx
new file mode 100644
index 0000000..41d0d68
--- /dev/null
+++ b/src/components/git-panel/view/changes/CommitComposer.tsx
@@ -0,0 +1,162 @@
+import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
+import { useState } from 'react';
+import MicButton from '../../../mic-button/view/MicButton';
+import type { ConfirmationRequest } from '../../types/types';
+
+type CommitComposerProps = {
+ isMobile: boolean;
+ selectedFileCount: number;
+ isHidden: boolean;
+ onCommit: (message: string) => Promise;
+ onGenerateMessage: () => Promise;
+ onRequestConfirmation: (request: ConfirmationRequest) => void;
+};
+
+export default function CommitComposer({
+ isMobile,
+ selectedFileCount,
+ isHidden,
+ onCommit,
+ onGenerateMessage,
+ onRequestConfirmation,
+}: CommitComposerProps) {
+ const [commitMessage, setCommitMessage] = useState('');
+ const [isCommitting, setIsCommitting] = useState(false);
+ const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
+ const [isCollapsed, setIsCollapsed] = useState(isMobile);
+
+ const handleCommit = async (message = commitMessage) => {
+ const trimmedMessage = message.trim();
+ if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
+ return false;
+ }
+
+ setIsCommitting(true);
+ try {
+ const success = await onCommit(trimmedMessage);
+ if (success) {
+ setCommitMessage('');
+ }
+ return success;
+ } finally {
+ setIsCommitting(false);
+ }
+ };
+
+ const handleGenerateMessage = async () => {
+ if (selectedFileCount === 0 || isGeneratingMessage) {
+ return;
+ }
+
+ setIsGeneratingMessage(true);
+ try {
+ const generatedMessage = await onGenerateMessage();
+ if (generatedMessage) {
+ setCommitMessage(generatedMessage);
+ }
+ } finally {
+ setIsGeneratingMessage(false);
+ }
+ };
+
+ const requestCommitConfirmation = () => {
+ const trimmedMessage = commitMessage.trim();
+ if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
+ return;
+ }
+
+ onRequestConfirmation({
+ type: 'commit',
+ message: `Commit ${selectedFileCount} file${selectedFileCount !== 1 ? 's' : ''} with message: "${trimmedMessage}"?`,
+ onConfirm: async () => {
+ await handleCommit(trimmedMessage);
+ },
+ });
+ };
+
+ return (
+
+ {isMobile && isCollapsed ? (
+
+ setIsCollapsed(false)}
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
+ >
+
+ Commit {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''}
+
+
+
+ ) : (
+
+ {isMobile && (
+
+ Commit Changes
+ setIsCollapsed(true)}
+ className="p-1 hover:bg-accent rounded-lg transition-colors"
+ >
+
+
+
+ )}
+
+
+
setCommitMessage(event.target.value)}
+ placeholder="Message (Ctrl+Enter to commit)"
+ className="w-full px-3 py-2 text-sm border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground resize-none pr-20 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
+ rows={3}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+ event.preventDefault();
+ void handleCommit();
+ }
+ }}
+ />
+
+
void handleGenerateMessage()}
+ disabled={selectedFileCount === 0 || isGeneratingMessage}
+ className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ title="Generate commit message"
+ >
+ {isGeneratingMessage ? (
+
+ ) : (
+
+ )}
+
+
+ setCommitMessage(transcript)}
+ mode="default"
+ className="p-1.5"
+ />
+
+
+
+
+
+
+ {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''} selected
+
+
+
+ {isCommitting ? 'Committing...' : 'Commit'}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileChangeItem.tsx b/src/components/git-panel/view/changes/FileChangeItem.tsx
new file mode 100644
index 0000000..2d4bac4
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileChangeItem.tsx
@@ -0,0 +1,138 @@
+import { ChevronRight, Trash2 } from 'lucide-react';
+import DiffViewer from '../../../DiffViewer.jsx';
+import type { FileStatusCode } from '../../types/types';
+import { getStatusBadgeClass, getStatusLabel } from '../../utils/gitPanelUtils';
+
+type DiffViewerProps = {
+ diff: string;
+ fileName: string;
+ isMobile: boolean;
+ wrapText: boolean;
+};
+
+const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
+
+type FileChangeItemProps = {
+ filePath: string;
+ status: FileStatusCode;
+ isMobile: boolean;
+ isExpanded: boolean;
+ isSelected: boolean;
+ diff?: string;
+ wrapText: boolean;
+ onToggleSelected: (filePath: string) => void;
+ onToggleExpanded: (filePath: string) => void;
+ onOpenFile: (filePath: string) => void;
+ onToggleWrapText: () => void;
+ onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
+};
+
+export default function FileChangeItem({
+ filePath,
+ status,
+ isMobile,
+ isExpanded,
+ isSelected,
+ diff,
+ wrapText,
+ onToggleSelected,
+ onToggleExpanded,
+ onOpenFile,
+ onToggleWrapText,
+ onRequestFileAction,
+}: FileChangeItemProps) {
+ const statusLabel = getStatusLabel(status);
+ const badgeClass = getStatusBadgeClass(status);
+
+ return (
+
+
+
onToggleSelected(filePath)}
+ onClick={(event) => event.stopPropagation()}
+ className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
+ />
+
+
+ {
+ event.stopPropagation();
+ onToggleExpanded(filePath);
+ }}
+ className={`p-0.5 hover:bg-accent rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
+ title={isExpanded ? 'Collapse diff' : 'Expand diff'}
+ >
+
+
+
+ {
+ event.stopPropagation();
+ onOpenFile(filePath);
+ }}
+ title="Click to open file"
+ >
+ {filePath}
+
+
+
+ {(status === 'M' || status === 'D' || status === 'U') && (
+ {
+ event.stopPropagation();
+ onRequestFileAction(filePath, status);
+ }}
+ className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
+ title={status === 'U' ? 'Delete untracked file' : 'Discard changes'}
+ >
+
+ {isMobile && {status === 'U' ? 'Delete' : 'Discard'} }
+
+ )}
+
+
+ {status}
+
+
+
+
+
+
+
+
+
+ {status}
+
+ {statusLabel}
+
+ {isMobile && (
+ {
+ event.stopPropagation();
+ onToggleWrapText();
+ }}
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+ title={wrapText ? 'Switch to horizontal scroll' : 'Switch to text wrap'}
+ >
+ {wrapText ? 'Scroll' : 'Wrap'}
+
+ )}
+
+
+
+ {diff && }
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileChangeList.tsx b/src/components/git-panel/view/changes/FileChangeList.tsx
new file mode 100644
index 0000000..0438382
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileChangeList.tsx
@@ -0,0 +1,55 @@
+import { FILE_STATUS_GROUPS } from '../../constants/constants';
+import type { FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
+import FileChangeItem from './FileChangeItem';
+
+type FileChangeListProps = {
+ gitStatus: GitStatusResponse;
+ gitDiff: GitDiffMap;
+ expandedFiles: Set;
+ selectedFiles: Set;
+ isMobile: boolean;
+ wrapText: boolean;
+ onToggleSelected: (filePath: string) => void;
+ onToggleExpanded: (filePath: string) => void;
+ onOpenFile: (filePath: string) => void;
+ onToggleWrapText: () => void;
+ onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
+};
+
+export default function FileChangeList({
+ gitStatus,
+ gitDiff,
+ expandedFiles,
+ selectedFiles,
+ isMobile,
+ wrapText,
+ onToggleSelected,
+ onToggleExpanded,
+ onOpenFile,
+ onToggleWrapText,
+ onRequestFileAction,
+}: FileChangeListProps) {
+ return (
+ <>
+ {FILE_STATUS_GROUPS.map(({ key, status }) =>
+ (gitStatus[key] || []).map((filePath) => (
+
+ )),
+ )}
+ >
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileSelectionControls.tsx b/src/components/git-panel/view/changes/FileSelectionControls.tsx
new file mode 100644
index 0000000..ed8f9a3
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileSelectionControls.tsx
@@ -0,0 +1,44 @@
+type FileSelectionControlsProps = {
+ isMobile: boolean;
+ selectedCount: number;
+ totalCount: number;
+ isHidden: boolean;
+ onSelectAll: () => void;
+ onDeselectAll: () => void;
+};
+
+export default function FileSelectionControls({
+ isMobile,
+ selectedCount,
+ totalCount,
+ isHidden,
+ onSelectAll,
+ onDeselectAll,
+}: FileSelectionControlsProps) {
+ return (
+
+
+ {selectedCount} of {totalCount} {isMobile ? '' : 'files'} selected
+
+
+
+ {isMobile ? 'All' : 'Select All'}
+
+ |
+
+ {isMobile ? 'None' : 'Deselect All'}
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/changes/FileStatusLegend.tsx b/src/components/git-panel/view/changes/FileStatusLegend.tsx
new file mode 100644
index 0000000..c2b3292
--- /dev/null
+++ b/src/components/git-panel/view/changes/FileStatusLegend.tsx
@@ -0,0 +1,52 @@
+import { ChevronDown, ChevronRight, Info } from 'lucide-react';
+import { useState } from 'react';
+import { getStatusBadgeClass } from '../../utils/gitPanelUtils';
+
+type FileStatusLegendProps = {
+ isMobile: boolean;
+};
+
+const LEGEND_ITEMS = [
+ { status: 'M', label: 'Modified' },
+ { status: 'A', label: 'Added' },
+ { status: 'D', label: 'Deleted' },
+ { status: 'U', label: 'Untracked' },
+] as const;
+
+export default function FileStatusLegend({ isMobile }: FileStatusLegendProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ if (isMobile) {
+ return null;
+ }
+
+ return (
+
+
setIsOpen((previous) => !previous)}
+ className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-sm text-muted-foreground flex items-center justify-center gap-1 transition-colors"
+ >
+
+ File Status Guide
+ {isOpen ? : }
+
+
+ {isOpen && (
+
+
+ {LEGEND_ITEMS.map((item) => (
+
+
+ {item.status}
+
+ {item.label}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/history/CommitHistoryItem.tsx b/src/components/git-panel/view/history/CommitHistoryItem.tsx
new file mode 100644
index 0000000..c7c7f85
--- /dev/null
+++ b/src/components/git-panel/view/history/CommitHistoryItem.tsx
@@ -0,0 +1,71 @@
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import DiffViewer from '../../../DiffViewer.jsx';
+import type { GitCommitSummary } from '../../types/types';
+
+type DiffViewerProps = {
+ diff: string;
+ fileName: string;
+ isMobile: boolean;
+ wrapText: boolean;
+};
+
+const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
+
+type CommitHistoryItemProps = {
+ commit: GitCommitSummary;
+ isExpanded: boolean;
+ diff?: string;
+ isMobile: boolean;
+ wrapText: boolean;
+ onToggle: () => void;
+};
+
+export default function CommitHistoryItem({
+ commit,
+ isExpanded,
+ diff,
+ isMobile,
+ wrapText,
+ onToggle,
+}: CommitHistoryItemProps) {
+ return (
+
+
+
+ {isExpanded ? : }
+
+
+
+
+
{commit.message}
+
+ {commit.author}
+ {' \u2022 '}
+ {commit.date}
+
+
+
+ {commit.hash.substring(0, 7)}
+
+
+
+
+
+ {isExpanded && diff && (
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/history/HistoryView.tsx b/src/components/git-panel/view/history/HistoryView.tsx
new file mode 100644
index 0000000..75c81e8
--- /dev/null
+++ b/src/components/git-panel/view/history/HistoryView.tsx
@@ -0,0 +1,77 @@
+import { History, RefreshCw } from 'lucide-react';
+import { useCallback, useState } from 'react';
+import type { GitDiffMap, GitCommitSummary } from '../../types/types';
+import CommitHistoryItem from './CommitHistoryItem';
+
+type HistoryViewProps = {
+ isMobile: boolean;
+ isLoading: boolean;
+ recentCommits: GitCommitSummary[];
+ commitDiffs: GitDiffMap;
+ wrapText: boolean;
+ onFetchCommitDiff: (commitHash: string) => Promise;
+};
+
+export default function HistoryView({
+ isMobile,
+ isLoading,
+ recentCommits,
+ commitDiffs,
+ wrapText,
+ onFetchCommitDiff,
+}: HistoryViewProps) {
+ const [expandedCommits, setExpandedCommits] = useState>(new Set());
+
+ const toggleCommitExpanded = useCallback(
+ (commitHash: string) => {
+ const isExpanding = !expandedCommits.has(commitHash);
+
+ setExpandedCommits((previous) => {
+ const next = new Set(previous);
+ if (next.has(commitHash)) {
+ next.delete(commitHash);
+ } else {
+ next.add(commitHash);
+ }
+ return next;
+ });
+
+ // Load commit diff lazily only the first time a commit is expanded.
+ if (isExpanding && !commitDiffs[commitHash]) {
+ onFetchCommitDiff(commitHash).catch((err) => {
+ console.error('Failed to fetch commit diff:', err);
+ });
+ }
+ },
+ [commitDiffs, expandedCommits, onFetchCommitDiff, setExpandedCommits],
+ );
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : recentCommits.length === 0 ? (
+
+ ) : (
+
+ {recentCommits.map((commit) => (
+ toggleCommitExpanded(commit.hash)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/git-panel/view/modals/ConfirmActionModal.tsx b/src/components/git-panel/view/modals/ConfirmActionModal.tsx
new file mode 100644
index 0000000..78f8b04
--- /dev/null
+++ b/src/components/git-panel/view/modals/ConfirmActionModal.tsx
@@ -0,0 +1,97 @@
+import { useEffect } from 'react';
+import { Check, Download, Trash2, Upload } from 'lucide-react';
+import {
+ CONFIRMATION_ACTION_LABELS,
+ CONFIRMATION_BUTTON_CLASSES,
+ CONFIRMATION_ICON_CONTAINER_CLASSES,
+ CONFIRMATION_TITLES,
+} from '../../constants/constants';
+import type { ConfirmationRequest } from '../../types/types';
+
+type ConfirmActionModalProps = {
+ action: ConfirmationRequest | null;
+ onCancel: () => void;
+ onConfirm: () => void;
+};
+
+function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
+ if (actionType === 'discard' || actionType === 'delete') {
+ return ;
+ }
+
+ if (actionType === 'commit') {
+ return ;
+ }
+
+ if (actionType === 'pull') {
+ return ;
+ }
+
+ return ;
+}
+
+export default function ConfirmActionModal({ action, onCancel, onConfirm }: ConfirmActionModalProps) {
+ const titleId = action ? `confirmation-title-${action.type}` : undefined;
+
+ useEffect(() => {
+ if (!action) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onCancel();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [action, onCancel]);
+
+ if (!action) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ {renderConfirmActionIcon(action.type)}
+
+
+ {CONFIRMATION_TITLES[action.type]}
+
+
+
+
{action.message}
+
+
+
+ Cancel
+
+
+ {renderConfirmActionIcon(action.type)}
+ {CONFIRMATION_ACTION_LABELS[action.type]}
+
+
+
+
+
+ );
+}
diff --git a/src/components/git-panel/view/modals/NewBranchModal.tsx b/src/components/git-panel/view/modals/NewBranchModal.tsx
new file mode 100644
index 0000000..1c95f77
--- /dev/null
+++ b/src/components/git-panel/view/modals/NewBranchModal.tsx
@@ -0,0 +1,124 @@
+import { Plus, RefreshCw } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+type NewBranchModalProps = {
+ isOpen: boolean;
+ currentBranch: string;
+ isCreatingBranch: boolean;
+ onClose: () => void;
+ onCreateBranch: (branchName: string) => Promise;
+};
+
+export default function NewBranchModal({
+ isOpen,
+ currentBranch,
+ isCreatingBranch,
+ onClose,
+ onCreateBranch,
+}: NewBranchModalProps) {
+ const [newBranchName, setNewBranchName] = useState('');
+
+ useEffect(() => {
+ if (!isOpen) {
+ setNewBranchName('');
+ }
+ }, [isOpen]);
+
+ const handleCreateBranch = async (): Promise => {
+ const branchName = newBranchName.trim();
+ if (!branchName) {
+ return false;
+ }
+
+ try {
+ const success = await onCreateBranch(branchName);
+ if (success) {
+ setNewBranchName('');
+ onClose();
+ }
+ return success;
+ } catch (error) {
+ console.error('Failed to create branch:', error);
+ return false;
+ }
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
Create New Branch
+
+
+
+ Branch Name
+
+ setNewBranchName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !isCreatingBranch) {
+ event.preventDefault();
+ event.stopPropagation();
+ void handleCreateBranch();
+ return;
+ }
+
+ if (event.key === 'Escape' && !isCreatingBranch) {
+ event.preventDefault();
+ event.stopPropagation();
+ onClose();
+ }
+ }}
+ placeholder="feature/new-feature"
+ className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
+ autoFocus
+ />
+
+
+
+ This will create a new branch from the current branch ({currentBranch})
+
+
+
+
+ Cancel
+
+
void handleCreateBranch()}
+ disabled={!newBranchName.trim() || isCreatingBranch}
+ className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 transition-colors"
+ >
+ {isCreatingBranch ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Branch
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ClaudeLogo.jsx b/src/components/llm-logo-provider/ClaudeLogo.tsx
similarity index 56%
rename from src/components/ClaudeLogo.jsx
rename to src/components/llm-logo-provider/ClaudeLogo.tsx
index b751fc1..d15a071 100644
--- a/src/components/ClaudeLogo.jsx
+++ b/src/components/llm-logo-provider/ClaudeLogo.tsx
@@ -1,6 +1,10 @@
import React from 'react';
-const ClaudeLogo = ({className = 'w-5 h-5'}) => {
+type ClaudeLogoProps = {
+ className?: string;
+};
+
+const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => {
return (
);
diff --git a/src/components/CodexLogo.jsx b/src/components/llm-logo-provider/CodexLogo.tsx
similarity index 58%
rename from src/components/CodexLogo.jsx
rename to src/components/llm-logo-provider/CodexLogo.tsx
index 91759dd..0c3a65f 100644
--- a/src/components/CodexLogo.jsx
+++ b/src/components/llm-logo-provider/CodexLogo.tsx
@@ -1,7 +1,11 @@
import React from 'react';
-import { useTheme } from '../contexts/ThemeContext';
+import { useTheme } from '../../contexts/ThemeContext';
-const CodexLogo = ({ className = 'w-5 h-5' }) => {
+type CodexLogoProps = {
+ className?: string;
+};
+
+const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => {
const { isDarkMode } = useTheme();
return (
diff --git a/src/components/CursorLogo.jsx b/src/components/llm-logo-provider/CursorLogo.tsx
similarity index 58%
rename from src/components/CursorLogo.jsx
rename to src/components/llm-logo-provider/CursorLogo.tsx
index 1cac4d2..a44064a 100644
--- a/src/components/CursorLogo.jsx
+++ b/src/components/llm-logo-provider/CursorLogo.tsx
@@ -1,7 +1,11 @@
import React from 'react';
-import { useTheme } from '../contexts/ThemeContext';
+import { useTheme } from '../../contexts/ThemeContext';
-const CursorLogo = ({ className = 'w-5 h-5' }) => {
+type CursorLogoProps = {
+ className?: string;
+};
+
+const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => {
const { isDarkMode } = useTheme();
return (
diff --git a/src/components/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx
similarity index 90%
rename from src/components/SessionProviderLogo.tsx
rename to src/components/llm-logo-provider/SessionProviderLogo.tsx
index d621fc5..c80efe1 100644
--- a/src/components/SessionProviderLogo.tsx
+++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx
@@ -1,4 +1,4 @@
-import type { SessionProvider } from '../types/app';
+import type { SessionProvider } from '../../types/app';
import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts
index cb49bcf..d4e708d 100644
--- a/src/components/main-content/types/types.ts
+++ b/src/components/main-content/types/types.ts
@@ -1,23 +1,9 @@
-import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react';
+import type { Dispatch, SetStateAction } from 'react';
import type { AppTab, Project, ProjectSession } from '../../../types/app';
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
-export interface DiffInfo {
- old_string?: string;
- new_string?: string;
- [key: string]: unknown;
-}
-
-export interface EditingFile {
- name: string;
- path: string;
- projectName?: string;
- diffInfo?: DiffInfo | null;
- [key: string]: unknown;
-}
-
-export interface TaskMasterTask {
+export type TaskMasterTask = {
id: string | number;
title?: string;
description?: string;
@@ -29,24 +15,24 @@ export interface TaskMasterTask {
dependencies?: Array;
subtasks?: TaskMasterTask[];
[key: string]: unknown;
-}
+};
-export interface TaskReference {
+export type TaskReference = {
id: string | number;
title?: string;
[key: string]: unknown;
-}
+};
export type TaskSelection = TaskMasterTask | TaskReference;
-export interface PrdFile {
+export type PrdFile = {
name: string;
content?: string;
isExisting?: boolean;
[key: string]: unknown;
-}
+};
-export interface MainContentProps {
+export type MainContentProps = {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
activeTab: AppTab;
@@ -67,9 +53,9 @@ export interface MainContentProps {
onNavigateToSession: (targetSessionId: string) => void;
onShowSettings: () => void;
externalMessageUpdate: number;
-}
+};
-export interface MainContentHeaderProps {
+export type MainContentHeaderProps = {
activeTab: AppTab;
setActiveTab: Dispatch>;
selectedProject: Project;
@@ -77,32 +63,19 @@ export interface MainContentHeaderProps {
shouldShowTasksTab: boolean;
isMobile: boolean;
onMenuClick: () => void;
-}
+};
-export interface MainContentStateViewProps {
+export type MainContentStateViewProps = {
mode: 'loading' | 'empty';
isMobile: boolean;
onMenuClick: () => void;
-}
+};
-export interface MobileMenuButtonProps {
+export type MobileMenuButtonProps = {
onMenuClick: () => void;
compact?: boolean;
-}
+};
-export interface EditorSidebarProps {
- editingFile: EditingFile | null;
- isMobile: boolean;
- editorExpanded: boolean;
- editorWidth: number;
- resizeHandleRef: RefObject;
- onResizeStart: (event: MouseEvent) => void;
- onCloseEditor: () => void;
- onToggleEditorExpand: () => void;
- projectPath?: string;
- fillSpace?: boolean;
-}
-
-export interface TaskMasterPanelProps {
+export type TaskMasterPanelProps = {
isVisible: boolean;
-}
+};
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
index 1279436..e0c3dbf 100644
--- a/src/components/main-content/view/MainContent.tsx
+++ b/src/components/main-content/view/MainContent.tsx
@@ -1,26 +1,23 @@
import React, { useEffect } from 'react';
import ChatInterface from '../../chat/view/ChatInterface';
-import FileTree from '../../FileTree';
-import StandaloneShell from '../../StandaloneShell';
-import GitPanel from '../../GitPanel';
+import FileTree from '../../file-tree/view/FileTree';
+import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
+import GitPanel from '../../git-panel/view/GitPanel';
import ErrorBoundary from '../../ErrorBoundary';
import MainContentHeader from './subcomponents/MainContentHeader';
import MainContentStateView from './subcomponents/MainContentStateView';
-import EditorSidebar from './subcomponents/EditorSidebar';
import TaskMasterPanel from './subcomponents/TaskMasterPanel';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
-import { useEditorSidebar } from '../hooks/useEditorSidebar';
+import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
+import EditorSidebar from '../../code-editor/view/EditorSidebar';
import type { Project } from '../../../types/app';
-const AnyStandaloneShell = StandaloneShell as any;
-const AnyGitPanel = GitPanel as any;
-
type TaskMasterContextValue = {
currentProject?: Project | null;
setCurrentProject?: ((project: Project) => void) | null;
@@ -66,6 +63,7 @@ function MainContent({
editingFile,
editorWidth,
editorExpanded,
+ hasManualWidth,
resizeHandleRef,
handleFileOpen,
handleCloseEditor,
@@ -109,7 +107,7 @@ function MainContent({
/>
-
+
)}
{activeTab === 'git' && (
)}
@@ -167,6 +165,7 @@ function MainContent({
isMobile={isMobile}
editorExpanded={editorExpanded}
editorWidth={editorWidth}
+ hasManualWidth={hasManualWidth}
resizeHandleRef={resizeHandleRef}
onResizeStart={handleResizeStart}
onCloseEditor={handleCloseEditor}
diff --git a/src/components/main-content/view/subcomponents/MainContentTitle.tsx b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
index 3d11d1e..a9f095c 100644
--- a/src/components/main-content/view/subcomponents/MainContentTitle.tsx
+++ b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
-import SessionProviderLogo from '../../../SessionProviderLogo';
+import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
type MainContentTitleProps = {
diff --git a/src/components/mic-button/constants/constants.ts b/src/components/mic-button/constants/constants.ts
new file mode 100644
index 0000000..3bfbe62
--- /dev/null
+++ b/src/components/mic-button/constants/constants.ts
@@ -0,0 +1,45 @@
+import type { MicButtonState } from '../types/types';
+
+export const MIC_BUTTON_STATES = {
+ IDLE: 'idle',
+ RECORDING: 'recording',
+ TRANSCRIBING: 'transcribing',
+ PROCESSING: 'processing',
+} as const;
+
+export const MIC_TAP_DEBOUNCE_MS = 300;
+export const PROCESSING_STATE_DELAY_MS = 2000;
+
+export const DEFAULT_WHISPER_MODE = 'default';
+
+// Modes that use post-transcription enhancement on the backend.
+export const ENHANCEMENT_WHISPER_MODES = new Set([
+ 'prompt',
+ 'vibe',
+ 'instructions',
+ 'architect',
+]);
+
+export const BUTTON_BACKGROUND_BY_STATE: Record
= {
+ idle: '#374151',
+ recording: '#ef4444',
+ transcribing: '#3b82f6',
+ processing: '#a855f7',
+};
+
+export const MIC_ERROR_BY_NAME = {
+ NotAllowedError: 'Microphone access denied. Please allow microphone permissions.',
+ NotFoundError: 'No microphone found. Please check your audio devices.',
+ NotSupportedError: 'Microphone not supported by this browser.',
+ NotReadableError: 'Microphone is being used by another application.',
+} as const;
+
+export const MIC_NOT_AVAILABLE_ERROR =
+ 'Microphone access not available. Please use HTTPS or a supported browser.';
+
+export const MIC_NOT_SUPPORTED_ERROR =
+ 'Microphone not supported. Please use HTTPS or a modern browser.';
+
+export const MIC_SECURE_CONTEXT_ERROR =
+ 'Microphone requires HTTPS. Please use a secure connection.';
+
diff --git a/src/components/mic-button/data/whisper.ts b/src/components/mic-button/data/whisper.ts
new file mode 100644
index 0000000..be204d3
--- /dev/null
+++ b/src/components/mic-button/data/whisper.ts
@@ -0,0 +1,52 @@
+import { api } from '../../../utils/api';
+
+type WhisperStatus = 'transcribing';
+
+type WhisperResponse = {
+ text?: string;
+ error?: string;
+};
+
+export async function transcribeWithWhisper(
+ audioBlob: Blob,
+ onStatusChange?: (status: WhisperStatus) => void,
+): Promise {
+ const formData = new FormData();
+ const fileName = `recording_${Date.now()}.webm`;
+ const file = new File([audioBlob], fileName, { type: audioBlob.type });
+
+ formData.append('audio', file);
+
+ const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
+ formData.append('mode', whisperMode);
+
+ try {
+ // Keep existing status callback behavior.
+ if (onStatusChange) {
+ onStatusChange('transcribing');
+ }
+
+ const response = (await api.transcribe(formData)) as Response;
+
+ if (!response.ok) {
+ const errorData = (await response.json().catch(() => ({}))) as WhisperResponse;
+ throw new Error(
+ errorData.error ||
+ `Transcription error: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const data = (await response.json()) as WhisperResponse;
+ return data.text || '';
+ } catch (error) {
+ if (
+ error instanceof Error
+ && error.name === 'TypeError'
+ && error.message.includes('fetch')
+ ) {
+ throw new Error('Cannot connect to server. Please ensure the backend is running.');
+ }
+ throw error;
+ }
+}
+
diff --git a/src/components/mic-button/hooks/useMicButtonController.ts b/src/components/mic-button/hooks/useMicButtonController.ts
new file mode 100644
index 0000000..dfddec7
--- /dev/null
+++ b/src/components/mic-button/hooks/useMicButtonController.ts
@@ -0,0 +1,204 @@
+import { useEffect, useRef, useState } from 'react';
+import type { MouseEvent } from 'react';
+import { transcribeWithWhisper } from '../data/whisper';
+import {
+ DEFAULT_WHISPER_MODE,
+ ENHANCEMENT_WHISPER_MODES,
+ MIC_BUTTON_STATES,
+ MIC_ERROR_BY_NAME,
+ MIC_NOT_AVAILABLE_ERROR,
+ MIC_NOT_SUPPORTED_ERROR,
+ MIC_SECURE_CONTEXT_ERROR,
+ MIC_TAP_DEBOUNCE_MS,
+ PROCESSING_STATE_DELAY_MS,
+} from '../constants/constants';
+import type { MicButtonState } from '../types/types';
+
+type UseMicButtonControllerArgs = {
+ onTranscript?: (transcript: string) => void;
+};
+
+type UseMicButtonControllerResult = {
+ state: MicButtonState;
+ error: string | null;
+ isSupported: boolean;
+ handleButtonClick: (event?: MouseEvent) => void;
+};
+
+const getRecordingErrorMessage = (error: unknown): string => {
+ if (error instanceof Error && error.message.includes('HTTPS')) {
+ return error.message;
+ }
+
+ if (error instanceof DOMException) {
+ return MIC_ERROR_BY_NAME[error.name as keyof typeof MIC_ERROR_BY_NAME] || 'Microphone access failed';
+ }
+
+ return 'Microphone access failed';
+};
+
+const getRecorderMimeType = (): string => (
+ MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'
+);
+
+export function useMicButtonController({
+ onTranscript,
+}: UseMicButtonControllerArgs): UseMicButtonControllerResult {
+ const [state, setState] = useState(MIC_BUTTON_STATES.IDLE);
+ const [error, setError] = useState(null);
+ const [isSupported, setIsSupported] = useState(true);
+
+ const mediaRecorderRef = useRef(null);
+ const streamRef = useRef(null);
+ const chunksRef = useRef([]);
+ const lastTapRef = useRef(0);
+ const processingTimerRef = useRef(null);
+
+ const clearProcessingTimer = (): void => {
+ if (processingTimerRef.current !== null) {
+ window.clearTimeout(processingTimerRef.current);
+ processingTimerRef.current = null;
+ }
+ };
+
+ const stopStreamTracks = (): void => {
+ if (!streamRef.current) {
+ return;
+ }
+
+ streamRef.current.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ };
+
+ const handleStopRecording = async (mimeType: string): Promise => {
+ const audioBlob = new Blob(chunksRef.current, { type: mimeType });
+
+ // Release the microphone immediately once recording ends.
+ stopStreamTracks();
+ setState(MIC_BUTTON_STATES.TRANSCRIBING);
+
+ const whisperMode = window.localStorage.getItem('whisperMode') || DEFAULT_WHISPER_MODE;
+ const shouldShowProcessingState = ENHANCEMENT_WHISPER_MODES.has(whisperMode);
+
+ if (shouldShowProcessingState) {
+ processingTimerRef.current = window.setTimeout(() => {
+ setState(MIC_BUTTON_STATES.PROCESSING);
+ }, PROCESSING_STATE_DELAY_MS);
+ }
+
+ try {
+ const transcript = await transcribeWithWhisper(audioBlob);
+ if (transcript && onTranscript) {
+ onTranscript(transcript);
+ }
+ } catch (transcriptionError) {
+ const message = transcriptionError instanceof Error ? transcriptionError.message : 'Transcription error';
+ setError(message);
+ } finally {
+ clearProcessingTimer();
+ setState(MIC_BUTTON_STATES.IDLE);
+ }
+ };
+
+ const startRecording = async (): Promise => {
+ try {
+ setError(null);
+ chunksRef.current = [];
+
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ throw new Error(MIC_NOT_AVAILABLE_ERROR);
+ }
+
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ streamRef.current = stream;
+
+ const mimeType = getRecorderMimeType();
+ const recorder = new MediaRecorder(stream, { mimeType });
+ mediaRecorderRef.current = recorder;
+
+ recorder.ondataavailable = (event: BlobEvent) => {
+ if (event.data.size > 0) {
+ chunksRef.current.push(event.data);
+ }
+ };
+
+ recorder.onstop = () => {
+ void handleStopRecording(mimeType);
+ };
+
+ recorder.start();
+ setState(MIC_BUTTON_STATES.RECORDING);
+ } catch (recordingError) {
+ stopStreamTracks();
+ setError(getRecordingErrorMessage(recordingError));
+ setState(MIC_BUTTON_STATES.IDLE);
+ }
+ };
+
+ const stopRecording = (): void => {
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
+ mediaRecorderRef.current.stop();
+ return;
+ }
+
+ stopStreamTracks();
+ setState(MIC_BUTTON_STATES.IDLE);
+ };
+
+ const handleButtonClick = (event?: MouseEvent): void => {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (!isSupported) {
+ return;
+ }
+
+ // Mobile tap handling can trigger duplicate click events in quick succession.
+ const now = Date.now();
+ if (now - lastTapRef.current < MIC_TAP_DEBOUNCE_MS) {
+ return;
+ }
+ lastTapRef.current = now;
+
+ if (state === MIC_BUTTON_STATES.IDLE) {
+ void startRecording();
+ return;
+ }
+
+ if (state === MIC_BUTTON_STATES.RECORDING) {
+ stopRecording();
+ }
+ };
+
+ useEffect(() => {
+ // getUserMedia needs both browser support and a secure context.
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ setIsSupported(false);
+ setError(MIC_NOT_SUPPORTED_ERROR);
+ return;
+ }
+
+ if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
+ setIsSupported(false);
+ setError(MIC_SECURE_CONTEXT_ERROR);
+ return;
+ }
+
+ setIsSupported(true);
+ setError(null);
+ }, []);
+
+ useEffect(() => () => {
+ clearProcessingTimer();
+ stopStreamTracks();
+ }, []);
+
+ return {
+ state,
+ error,
+ isSupported,
+ handleButtonClick,
+ };
+}
diff --git a/src/components/mic-button/types/types.ts b/src/components/mic-button/types/types.ts
new file mode 100644
index 0000000..c046982
--- /dev/null
+++ b/src/components/mic-button/types/types.ts
@@ -0,0 +1,2 @@
+export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';
+
diff --git a/src/components/mic-button/view/MicButton.tsx b/src/components/mic-button/view/MicButton.tsx
new file mode 100644
index 0000000..72926ce
--- /dev/null
+++ b/src/components/mic-button/view/MicButton.tsx
@@ -0,0 +1,32 @@
+import { useMicButtonController } from '../hooks/useMicButtonController';
+import MicButtonView from './MicButtonView';
+
+type MicButtonProps = {
+ onTranscript?: (transcript: string) => void;
+ className?: string;
+ mode?: string;
+};
+
+export default function MicButton({
+ onTranscript,
+ className = '',
+ mode: _mode,
+}: MicButtonProps) {
+ const { state, error, isSupported, handleButtonClick } = useMicButtonController({
+ onTranscript,
+ });
+
+ // Keep `mode` in the public props for backwards compatibility.
+ void _mode;
+
+ return (
+
+ );
+}
+
diff --git a/src/components/mic-button/view/MicButtonView.tsx b/src/components/mic-button/view/MicButtonView.tsx
new file mode 100644
index 0000000..4220bab
--- /dev/null
+++ b/src/components/mic-button/view/MicButtonView.tsx
@@ -0,0 +1,86 @@
+import { Brain, Loader2, Mic } from 'lucide-react';
+import type { MouseEvent, ReactElement } from 'react';
+import { BUTTON_BACKGROUND_BY_STATE, MIC_BUTTON_STATES } from '../constants/constants';
+import type { MicButtonState } from '../types/types';
+
+type MicButtonViewProps = {
+ state: MicButtonState;
+ error: string | null;
+ isSupported: boolean;
+ className: string;
+ onButtonClick: (event?: MouseEvent) => void;
+};
+
+const getButtonIcon = (state: MicButtonState, isSupported: boolean): ReactElement => {
+ if (!isSupported) {
+ return ;
+ }
+
+ if (state === MIC_BUTTON_STATES.TRANSCRIBING) {
+ return ;
+ }
+
+ if (state === MIC_BUTTON_STATES.PROCESSING) {
+ return ;
+ }
+
+ if (state === MIC_BUTTON_STATES.RECORDING) {
+ return ;
+ }
+
+ return ;
+};
+
+export default function MicButtonView({
+ state,
+ error,
+ isSupported,
+ className,
+ onButtonClick,
+}: MicButtonViewProps) {
+ const isDisabled = !isSupported || state === MIC_BUTTON_STATES.TRANSCRIBING || state === MIC_BUTTON_STATES.PROCESSING;
+ const icon = getButtonIcon(state, isSupported);
+
+ return (
+
+
+ {icon}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {state === MIC_BUTTON_STATES.RECORDING && (
+
+ )}
+
+ {state === MIC_BUTTON_STATES.PROCESSING && (
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/McpServersContent.jsx b/src/components/settings/McpServersContent.jsx
deleted file mode 100644
index 257f58a..0000000
--- a/src/components/settings/McpServersContent.jsx
+++ /dev/null
@@ -1,319 +0,0 @@
-import { useState } from 'react';
-import { Button } from '../ui/button';
-import { Input } from '../ui/input';
-import { Badge } from '../ui/badge';
-import { Server, Plus, Edit3, Trash2, Terminal, Globe, Zap, X } from 'lucide-react';
-import { useTranslation } from 'react-i18next';
-
-const getTransportIcon = (type) => {
- switch (type) {
- case 'stdio': return ;
- case 'sse': return ;
- case 'http': return ;
- default: return ;
- }
-};
-
-// Claude MCP Servers
-function ClaudeMcpServers({
- servers,
- onAdd,
- onEdit,
- onDelete,
- onTest,
- onDiscoverTools,
- testResults,
- serverTools,
- toolsLoading,
-}) {
- const { t } = useTranslation('settings');
- return (
-
-
-
-
- {t('mcpServers.title')}
-
-
-
- {t('mcpServers.description.claude')}
-
-
-
-
-
- {t('mcpServers.addButton')}
-
-
-
-
- {servers.map(server => (
-
-
-
-
- {getTransportIcon(server.type)}
- {server.name}
-
- {server.type}
-
-
- {server.scope === 'local' ? t('mcpServers.scope.local') : server.scope === 'user' ? t('mcpServers.scope.user') : server.scope}
-
-
-
-
- {server.type === 'stdio' && server.config?.command && (
-
{t('mcpServers.config.command')}: {server.config.command}
- )}
- {(server.type === 'sse' || server.type === 'http') && server.config?.url && (
-
{t('mcpServers.config.url')}: {server.config.url}
- )}
- {server.config?.args && server.config.args.length > 0 && (
-
{t('mcpServers.config.args')}: {server.config.args.join(' ')}
- )}
-
-
- {/* Test Results */}
- {testResults?.[server.id] && (
-
-
{testResults[server.id].message}
-
- )}
-
- {/* Tools Discovery Results */}
- {serverTools?.[server.id] && serverTools[server.id].tools?.length > 0 && (
-
-
{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: serverTools[server.id].tools.length })}
-
- {serverTools[server.id].tools.slice(0, 5).map((tool, i) => (
- {tool.name}
- ))}
- {serverTools[server.id].tools.length > 5 && (
- {t('mcpServers.tools.more', { count: serverTools[server.id].tools.length - 5 })}
- )}
-
-
- )}
-
-
-
- onEdit(server)}
- variant="ghost"
- size="sm"
- className="text-gray-600 hover:text-gray-700"
- title={t('mcpServers.actions.edit')}
- >
-
-
- onDelete(server.id, server.scope)}
- variant="ghost"
- size="sm"
- className="text-red-600 hover:text-red-700"
- title={t('mcpServers.actions.delete')}
- >
-
-
-
-
-
- ))}
- {servers.length === 0 && (
-
- {t('mcpServers.empty')}
-
- )}
-
-
- );
-}
-
-// Cursor MCP Servers
-function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
- const { t } = useTranslation('settings');
- return (
-
-
-
-
- {t('mcpServers.title')}
-
-
-
- {t('mcpServers.description.cursor')}
-
-
-
-
-
- {t('mcpServers.addButton')}
-
-
-
-
- {servers.map(server => (
-
-
-
-
-
- {server.name}
- stdio
-
-
- {server.config?.command && (
-
{t('mcpServers.config.command')}: {server.config.command}
- )}
-
-
-
- onEdit(server)}
- variant="ghost"
- size="sm"
- className="text-gray-600 hover:text-gray-700"
- title={t('mcpServers.actions.edit')}
- >
-
-
- onDelete(server.name)}
- variant="ghost"
- size="sm"
- className="text-red-600 hover:text-red-700"
- title={t('mcpServers.actions.delete')}
- >
-
-
-
-
-
- ))}
- {servers.length === 0 && (
-
- {t('mcpServers.empty')}
-
- )}
-
-
- );
-}
-
-// Codex MCP Servers
-function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
- const { t } = useTranslation('settings');
- return (
-
-
-
-
- {t('mcpServers.title')}
-
-
-
- {t('mcpServers.description.codex')}
-
-
-
-
-
- {t('mcpServers.addButton')}
-
-
-
-
- {servers.map(server => (
-
-
-
-
-
- {server.name}
- stdio
-
-
-
- {server.config?.command && (
-
{t('mcpServers.config.command')}: {server.config.command}
- )}
- {server.config?.args && server.config.args.length > 0 && (
-
{t('mcpServers.config.args')}: {server.config.args.join(' ')}
- )}
- {server.config?.env && Object.keys(server.config.env).length > 0 && (
-
{t('mcpServers.config.environment')}: {Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}
- )}
-
-
-
-
- onEdit(server)}
- variant="ghost"
- size="sm"
- className="text-gray-600 hover:text-gray-700"
- title={t('mcpServers.actions.edit')}
- >
-
-
- onDelete(server.name)}
- variant="ghost"
- size="sm"
- className="text-red-600 hover:text-red-700"
- title={t('mcpServers.actions.delete')}
- >
-
-
-
-
-
- ))}
- {servers.length === 0 && (
-
- {t('mcpServers.empty')}
-
- )}
-
-
- {/* Help Section */}
-
-
{t('mcpServers.help.title')}
-
- {t('mcpServers.help.description')}
-
-
-
- );
-}
-
-// Main component
-export default function McpServersContent({ agent, ...props }) {
- if (agent === 'claude') {
- return ;
- }
- if (agent === 'cursor') {
- return ;
- }
- if (agent === 'codex') {
- return ;
- }
- return null;
-}
diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts
new file mode 100644
index 0000000..dd11171
--- /dev/null
+++ b/src/components/settings/constants/constants.ts
@@ -0,0 +1,94 @@
+import type {
+ AgentCategory,
+ AgentProvider,
+ AuthStatus,
+ ClaudeMcpFormState,
+ CodexMcpFormState,
+ CodeEditorSettingsState,
+ CursorPermissionsState,
+ McpToolsResult,
+ McpTestResult,
+ ProjectSortOrder,
+ SettingsMainTab,
+} from '../types/types';
+
+export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
+ 'agents',
+ 'appearance',
+ 'git',
+ 'api',
+ 'tasks',
+];
+
+export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
+export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
+
+export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
+export const DEFAULT_SAVE_STATUS = null;
+export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
+ theme: 'dark',
+ wordWrap: false,
+ showMinimap: true,
+ lineNumbers: true,
+ fontSize: '14',
+};
+
+export const DEFAULT_AUTH_STATUS: AuthStatus = {
+ authenticated: false,
+ email: null,
+ loading: true,
+ error: null,
+};
+
+export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
+ success: false,
+ message: '',
+ details: [],
+ loading: false,
+};
+
+export const DEFAULT_MCP_TOOLS_RESULT: McpToolsResult = {
+ success: false,
+ tools: [],
+ resources: [],
+ prompts: [],
+};
+
+export const DEFAULT_CLAUDE_MCP_FORM: ClaudeMcpFormState = {
+ name: '',
+ type: 'stdio',
+ scope: 'user',
+ projectPath: '',
+ config: {
+ command: '',
+ args: [],
+ env: {},
+ url: '',
+ headers: {},
+ timeout: 30000,
+ },
+ importMode: 'form',
+ jsonInput: '',
+};
+
+export const DEFAULT_CODEX_MCP_FORM: CodexMcpFormState = {
+ name: '',
+ type: 'stdio',
+ config: {
+ command: '',
+ args: [],
+ env: {},
+ },
+};
+
+export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
+ allowedCommands: [],
+ disallowedCommands: [],
+ skipPermissions: false,
+};
+
+export const AUTH_STATUS_ENDPOINTS: Record = {
+ claude: '/api/cli/claude/status',
+ cursor: '/api/cli/cursor/status',
+ codex: '/api/cli/codex/status',
+};
diff --git a/src/components/settings/hooks/useCredentialsSettings.ts b/src/components/settings/hooks/useCredentialsSettings.ts
new file mode 100644
index 0000000..4a9afc4
--- /dev/null
+++ b/src/components/settings/hooks/useCredentialsSettings.ts
@@ -0,0 +1,273 @@
+import { useCallback, useEffect, useState } from 'react';
+import { authenticatedFetch } from '../../../utils/api';
+import type {
+ ApiKeyItem,
+ ApiKeysResponse,
+ CreatedApiKey,
+ GithubCredentialItem,
+ GithubCredentialsResponse,
+} from '../view/tabs/api-settings/types';
+import { copyTextToClipboard } from '../../../utils/clipboard';
+
+type UseCredentialsSettingsArgs = {
+ confirmDeleteApiKeyText: string;
+ confirmDeleteGithubCredentialText: string;
+};
+
+const getApiError = (payload: { error?: string } | undefined, fallback: string) => (
+ payload?.error || fallback
+);
+
+export function useCredentialsSettings({
+ confirmDeleteApiKeyText,
+ confirmDeleteGithubCredentialText,
+}: UseCredentialsSettingsArgs) {
+ const [apiKeys, setApiKeys] = useState([]);
+ const [githubCredentials, setGithubCredentials] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const [showNewKeyForm, setShowNewKeyForm] = useState(false);
+ const [newKeyName, setNewKeyName] = useState('');
+
+ const [showNewGithubForm, setShowNewGithubForm] = useState(false);
+ const [newGithubName, setNewGithubName] = useState('');
+ const [newGithubToken, setNewGithubToken] = useState('');
+ const [newGithubDescription, setNewGithubDescription] = useState('');
+
+ const [showToken, setShowToken] = useState>({});
+ const [copiedKey, setCopiedKey] = useState(null);
+ const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ try {
+ setLoading(true);
+
+ const [apiKeysResponse, credentialsResponse] = await Promise.all([
+ authenticatedFetch('/api/settings/api-keys'),
+ authenticatedFetch('/api/settings/credentials?type=github_token'),
+ ]);
+
+ const [apiKeysPayload, credentialsPayload] = await Promise.all([
+ apiKeysResponse.json() as Promise,
+ credentialsResponse.json() as Promise,
+ ]);
+
+ setApiKeys(apiKeysPayload.apiKeys || []);
+ setGithubCredentials(credentialsPayload.credentials || []);
+ } catch (error) {
+ console.error('Error fetching settings:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const createApiKey = useCallback(async () => {
+ if (!newKeyName.trim()) {
+ return;
+ }
+
+ try {
+ const response = await authenticatedFetch('/api/settings/api-keys', {
+ method: 'POST',
+ body: JSON.stringify({ keyName: newKeyName.trim() }),
+ });
+
+ const payload = await response.json() as ApiKeysResponse;
+ if (!response.ok || !payload.success) {
+ console.error('Error creating API key:', getApiError(payload, 'Failed to create API key'));
+ return;
+ }
+
+ if (payload.apiKey) {
+ setNewlyCreatedKey(payload.apiKey);
+ }
+ setNewKeyName('');
+ setShowNewKeyForm(false);
+ await fetchData();
+ } catch (error) {
+ console.error('Error creating API key:', error);
+ }
+ }, [fetchData, newKeyName]);
+
+ const deleteApiKey = useCallback(async (keyId: string) => {
+ if (!window.confirm(confirmDeleteApiKeyText)) {
+ return;
+ }
+
+ try {
+ const response = await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ const payload = await response.json() as ApiKeysResponse;
+ console.error('Error deleting API key:', getApiError(payload, 'Failed to delete API key'));
+ return;
+ }
+
+ await fetchData();
+ } catch (error) {
+ console.error('Error deleting API key:', error);
+ }
+ }, [confirmDeleteApiKeyText, fetchData]);
+
+ const toggleApiKey = useCallback(async (keyId: string, isActive: boolean) => {
+ try {
+ const response = await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
+ method: 'PATCH',
+ body: JSON.stringify({ isActive: !isActive }),
+ });
+
+ if (!response.ok) {
+ const payload = await response.json() as ApiKeysResponse;
+ console.error('Error toggling API key:', getApiError(payload, 'Failed to toggle API key'));
+ return;
+ }
+
+ await fetchData();
+ } catch (error) {
+ console.error('Error toggling API key:', error);
+ }
+ }, [fetchData]);
+
+ const createGithubCredential = useCallback(async () => {
+ if (!newGithubName.trim() || !newGithubToken.trim()) {
+ return;
+ }
+
+ try {
+ const response = await authenticatedFetch('/api/settings/credentials', {
+ method: 'POST',
+ body: JSON.stringify({
+ credentialName: newGithubName.trim(),
+ credentialType: 'github_token',
+ credentialValue: newGithubToken,
+ description: newGithubDescription.trim(),
+ }),
+ });
+
+ const payload = await response.json() as GithubCredentialsResponse;
+ if (!response.ok || !payload.success) {
+ console.error('Error creating GitHub credential:', getApiError(payload, 'Failed to create GitHub credential'));
+ return;
+ }
+
+ setNewGithubName('');
+ setNewGithubToken('');
+ setNewGithubDescription('');
+ setShowNewGithubForm(false);
+ setShowToken((prev) => ({ ...prev, new: false }));
+ await fetchData();
+ } catch (error) {
+ console.error('Error creating GitHub credential:', error);
+ }
+ }, [fetchData, newGithubDescription, newGithubName, newGithubToken]);
+
+ const deleteGithubCredential = useCallback(async (credentialId: string) => {
+ if (!window.confirm(confirmDeleteGithubCredentialText)) {
+ return;
+ }
+
+ try {
+ const response = await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ const payload = await response.json() as GithubCredentialsResponse;
+ console.error('Error deleting GitHub credential:', getApiError(payload, 'Failed to delete GitHub credential'));
+ return;
+ }
+
+ await fetchData();
+ } catch (error) {
+ console.error('Error deleting GitHub credential:', error);
+ }
+ }, [confirmDeleteGithubCredentialText, fetchData]);
+
+ const toggleGithubCredential = useCallback(async (credentialId: string, isActive: boolean) => {
+ try {
+ const response = await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {
+ method: 'PATCH',
+ body: JSON.stringify({ isActive: !isActive }),
+ });
+
+ if (!response.ok) {
+ const payload = await response.json() as GithubCredentialsResponse;
+ console.error('Error toggling GitHub credential:', getApiError(payload, 'Failed to toggle GitHub credential'));
+ return;
+ }
+
+ await fetchData();
+ } catch (error) {
+ console.error('Error toggling GitHub credential:', error);
+ }
+ }, [fetchData]);
+
+ const copyToClipboard = useCallback(async (text: string, id: string) => {
+ try {
+ await copyTextToClipboard(text);
+ setCopiedKey(id);
+ window.setTimeout(() => setCopiedKey(null), 2000);
+ } catch (error) {
+ console.error('Failed to copy to clipboard:', error);
+ }
+ }, []);
+
+ const dismissNewlyCreatedKey = useCallback(() => {
+ setNewlyCreatedKey(null);
+ }, []);
+
+ const cancelNewApiKeyForm = useCallback(() => {
+ setShowNewKeyForm(false);
+ setNewKeyName('');
+ }, []);
+
+ const cancelNewGithubForm = useCallback(() => {
+ setShowNewGithubForm(false);
+ setNewGithubName('');
+ setNewGithubToken('');
+ setNewGithubDescription('');
+ setShowToken((prev) => ({ ...prev, new: false }));
+ }, []);
+
+ const toggleNewGithubTokenVisibility = useCallback(() => {
+ setShowToken((prev) => ({ ...prev, new: !prev.new }));
+ }, []);
+
+ useEffect(() => {
+ void fetchData();
+ }, [fetchData]);
+
+ return {
+ apiKeys,
+ githubCredentials,
+ loading,
+ showNewKeyForm,
+ setShowNewKeyForm,
+ newKeyName,
+ setNewKeyName,
+ showNewGithubForm,
+ setShowNewGithubForm,
+ newGithubName,
+ setNewGithubName,
+ newGithubToken,
+ setNewGithubToken,
+ newGithubDescription,
+ setNewGithubDescription,
+ showToken,
+ copiedKey,
+ newlyCreatedKey,
+ createApiKey,
+ deleteApiKey,
+ toggleApiKey,
+ createGithubCredential,
+ deleteGithubCredential,
+ toggleGithubCredential,
+ copyToClipboard,
+ dismissNewlyCreatedKey,
+ cancelNewApiKeyForm,
+ cancelNewGithubForm,
+ toggleNewGithubTokenVisibility,
+ };
+}
diff --git a/src/components/settings/hooks/useGitSettings.ts b/src/components/settings/hooks/useGitSettings.ts
new file mode 100644
index 0000000..23392b0
--- /dev/null
+++ b/src/components/settings/hooks/useGitSettings.ts
@@ -0,0 +1,96 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { authenticatedFetch } from '../../../utils/api';
+
+type GitConfigResponse = {
+ gitName?: string;
+ gitEmail?: string;
+ error?: string;
+};
+
+type SaveStatus = 'success' | 'error' | null;
+
+export function useGitSettings() {
+ const [gitName, setGitName] = useState('');
+ const [gitEmail, setGitEmail] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveStatus, setSaveStatus] = useState(null);
+ const clearStatusTimerRef = useRef(null);
+
+ const clearSaveStatus = useCallback(() => {
+ if (clearStatusTimerRef.current !== null) {
+ window.clearTimeout(clearStatusTimerRef.current);
+ clearStatusTimerRef.current = null;
+ }
+ setSaveStatus(null);
+ }, []);
+
+ const loadGitConfig = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const response = await authenticatedFetch('/api/user/git-config');
+ if (!response.ok) {
+ return;
+ }
+
+ const data = await response.json() as GitConfigResponse;
+ setGitName(data.gitName || '');
+ setGitEmail(data.gitEmail || '');
+ } catch (error) {
+ console.error('Error loading git config:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ const saveGitConfig = useCallback(async () => {
+ try {
+ setIsSaving(true);
+ const response = await authenticatedFetch('/api/user/git-config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ gitName, gitEmail }),
+ });
+
+ if (response.ok) {
+ setSaveStatus('success');
+ clearStatusTimerRef.current = window.setTimeout(() => {
+ setSaveStatus(null);
+ clearStatusTimerRef.current = null;
+ }, 3000);
+ return;
+ }
+
+ const data = await response.json() as GitConfigResponse;
+ console.error('Failed to save git config:', data.error);
+ setSaveStatus('error');
+ } catch (error) {
+ console.error('Error saving git config:', error);
+ setSaveStatus('error');
+ } finally {
+ setIsSaving(false);
+ }
+ }, [gitEmail, gitName]);
+
+ useEffect(() => {
+ void loadGitConfig();
+ }, [loadGitConfig]);
+
+ useEffect(() => () => {
+ if (clearStatusTimerRef.current !== null) {
+ window.clearTimeout(clearStatusTimerRef.current);
+ }
+ }, []);
+
+ return {
+ gitName,
+ setGitName,
+ gitEmail,
+ setGitEmail,
+ isLoading,
+ isSaving,
+ saveStatus,
+ clearSaveStatus,
+ saveGitConfig,
+ };
+}
diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts
new file mode 100644
index 0000000..9e23319
--- /dev/null
+++ b/src/components/settings/hooks/useSettingsController.ts
@@ -0,0 +1,841 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTheme } from '../../../contexts/ThemeContext';
+import { authenticatedFetch } from '../../../utils/api';
+import {
+ AUTH_STATUS_ENDPOINTS,
+ DEFAULT_AUTH_STATUS,
+ DEFAULT_CODE_EDITOR_SETTINGS,
+ DEFAULT_CURSOR_PERMISSIONS,
+} from '../constants/constants';
+import type {
+ AgentProvider,
+ AuthStatus,
+ ClaudeMcpFormState,
+ ClaudePermissionsState,
+ CodeEditorSettingsState,
+ CodexMcpFormState,
+ CodexPermissionMode,
+ CursorPermissionsState,
+ McpServer,
+ McpToolsResult,
+ McpTestResult,
+ ProjectSortOrder,
+ SettingsMainTab,
+ SettingsProject,
+} from '../types/types';
+
+type ThemeContextValue = {
+ isDarkMode: boolean;
+ toggleDarkMode: () => void;
+};
+
+type UseSettingsControllerArgs = {
+ isOpen: boolean;
+ initialTab: string;
+ projects: SettingsProject[];
+ onClose: () => void;
+};
+
+type StatusApiResponse = {
+ authenticated?: boolean;
+ email?: string | null;
+ error?: string | null;
+};
+
+type JsonResult = {
+ success?: boolean;
+ error?: string;
+};
+
+type McpReadResponse = {
+ success?: boolean;
+ servers?: McpServer[];
+};
+
+type McpCliServer = {
+ name: string;
+ type?: string;
+ command?: string;
+ args?: string[];
+ env?: Record;
+ url?: string;
+ headers?: Record;
+};
+
+type McpCliReadResponse = {
+ success?: boolean;
+ servers?: McpCliServer[];
+};
+
+type McpTestResponse = {
+ testResult?: McpTestResult;
+ error?: string;
+};
+
+type McpToolsResponse = {
+ toolsResult?: McpToolsResult;
+ error?: string;
+};
+
+type ClaudeSettingsStorage = {
+ allowedTools?: string[];
+ disallowedTools?: string[];
+ skipPermissions?: boolean;
+ projectSortOrder?: ProjectSortOrder;
+};
+
+type CursorSettingsStorage = {
+ allowedCommands?: string[];
+ disallowedCommands?: string[];
+ skipPermissions?: boolean;
+};
+
+type CodexSettingsStorage = {
+ permissionMode?: CodexPermissionMode;
+};
+
+type ActiveLoginProvider = AgentProvider | '';
+
+const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks'];
+
+const normalizeMainTab = (tab: string): SettingsMainTab => {
+ // Keep backwards compatibility with older callers that still pass "tools".
+ if (tab === 'tools') {
+ return 'agents';
+ }
+
+ return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
+};
+
+const getErrorMessage = (error: unknown): string => (
+ error instanceof Error ? error.message : 'Unknown error'
+);
+
+const parseJson = (value: string | null, fallback: T): T => {
+ if (!value) {
+ return fallback;
+ }
+
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return fallback;
+ }
+};
+
+const toCodexPermissionMode = (value: unknown): CodexPermissionMode => {
+ if (value === 'acceptEdits' || value === 'bypassPermissions') {
+ return value;
+ }
+
+ return 'default';
+};
+
+const readCodeEditorSettings = (): CodeEditorSettingsState => ({
+ theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',
+ wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
+ showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
+ lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
+ fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
+});
+
+const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (
+ servers.map((server) => ({
+ id: server.name,
+ name: server.name,
+ type: server.type || 'stdio',
+ scope: 'user',
+ config: {
+ command: server.command || '',
+ args: server.args || [],
+ env: server.env || {},
+ url: server.url || '',
+ headers: server.headers || {},
+ timeout: 30000,
+ },
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ }))
+);
+
+const getDefaultProject = (projects: SettingsProject[]): SettingsProject => {
+ if (projects.length > 0) {
+ return projects[0];
+ }
+
+ const cwd = typeof process !== 'undefined' && process.cwd ? process.cwd() : '';
+ return {
+ name: 'default',
+ displayName: 'default',
+ fullPath: cwd,
+ path: cwd,
+ };
+};
+
+const toResponseJson = async (response: Response): Promise => response.json() as Promise;
+
+const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
+ allowedTools: [],
+ disallowedTools: [],
+ skipPermissions: false,
+});
+
+const createEmptyCursorPermissions = (): CursorPermissionsState => ({
+ ...DEFAULT_CURSOR_PERMISSIONS,
+});
+
+export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
+ const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
+ const closeTimerRef = useRef(null);
+
+ const [activeTab, setActiveTab] = useState(() => normalizeMainTab(initialTab));
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
+ const [deleteError, setDeleteError] = useState(null);
+ const [projectSortOrder, setProjectSortOrder] = useState('name');
+ const [codeEditorSettings, setCodeEditorSettings] = useState(() => (
+ readCodeEditorSettings()
+ ));
+
+ const [claudePermissions, setClaudePermissions] = useState(() => (
+ createEmptyClaudePermissions()
+ ));
+ const [cursorPermissions, setCursorPermissions] = useState(() => (
+ createEmptyCursorPermissions()
+ ));
+ const [codexPermissionMode, setCodexPermissionMode] = useState('default');
+
+ const [mcpServers, setMcpServers] = useState([]);
+ const [cursorMcpServers, setCursorMcpServers] = useState([]);
+ const [codexMcpServers, setCodexMcpServers] = useState([]);
+ const [mcpTestResults, setMcpTestResults] = useState>({});
+ const [mcpServerTools, setMcpServerTools] = useState>({});
+ const [mcpToolsLoading, setMcpToolsLoading] = useState>({});
+
+ const [showMcpForm, setShowMcpForm] = useState(false);
+ const [editingMcpServer, setEditingMcpServer] = useState(null);
+ const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
+ const [editingCodexMcpServer, setEditingCodexMcpServer] = useState(null);
+
+ const [showLoginModal, setShowLoginModal] = useState(false);
+ const [loginProvider, setLoginProvider] = useState('');
+ const [selectedProject, setSelectedProject] = useState(null);
+
+ const [claudeAuthStatus, setClaudeAuthStatus] = useState(DEFAULT_AUTH_STATUS);
+ const [cursorAuthStatus, setCursorAuthStatus] = useState(DEFAULT_AUTH_STATUS);
+ const [codexAuthStatus, setCodexAuthStatus] = useState(DEFAULT_AUTH_STATUS);
+
+ const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {
+ if (provider === 'claude') {
+ setClaudeAuthStatus(status);
+ return;
+ }
+
+ if (provider === 'cursor') {
+ setCursorAuthStatus(status);
+ return;
+ }
+
+ setCodexAuthStatus(status);
+ }, []);
+
+ const checkAuthStatus = useCallback(async (provider: AgentProvider) => {
+ try {
+ const response = await authenticatedFetch(AUTH_STATUS_ENDPOINTS[provider]);
+
+ if (!response.ok) {
+ setAuthStatusByProvider(provider, {
+ authenticated: false,
+ email: null,
+ loading: false,
+ error: 'Failed to check authentication status',
+ });
+ return;
+ }
+
+ const data = await toResponseJson(response);
+ setAuthStatusByProvider(provider, {
+ authenticated: Boolean(data.authenticated),
+ email: data.email || null,
+ loading: false,
+ error: data.error || null,
+ });
+ } catch (error) {
+ console.error(`Error checking ${provider} auth status:`, error);
+ setAuthStatusByProvider(provider, {
+ authenticated: false,
+ email: null,
+ loading: false,
+ error: getErrorMessage(error),
+ });
+ }
+ }, [setAuthStatusByProvider]);
+
+ const fetchCursorMcpServers = useCallback(async () => {
+ try {
+ const response = await authenticatedFetch('/api/cursor/mcp');
+ if (!response.ok) {
+ console.error('Failed to fetch Cursor MCP servers');
+ return;
+ }
+
+ const data = await toResponseJson<{ servers?: McpServer[] }>(response);
+ setCursorMcpServers(data.servers || []);
+ } catch (error) {
+ console.error('Error fetching Cursor MCP servers:', error);
+ }
+ }, []);
+
+ const fetchCodexMcpServers = useCallback(async () => {
+ try {
+ const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
+
+ if (configResponse.ok) {
+ const configData = await toResponseJson(configResponse);
+ if (configData.success && configData.servers) {
+ setCodexMcpServers(configData.servers);
+ return;
+ }
+ }
+
+ const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
+ if (!cliResponse.ok) {
+ return;
+ }
+
+ const cliData = await toResponseJson(cliResponse);
+ if (!cliData.success || !cliData.servers) {
+ return;
+ }
+
+ setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));
+ } catch (error) {
+ console.error('Error fetching Codex MCP servers:', error);
+ }
+ }, []);
+
+ const fetchMcpServers = useCallback(async () => {
+ try {
+ const configResponse = await authenticatedFetch('/api/mcp/config/read');
+ if (configResponse.ok) {
+ const configData = await toResponseJson(configResponse);
+ if (configData.success && configData.servers) {
+ setMcpServers(configData.servers);
+ return;
+ }
+ }
+
+ const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
+ if (cliResponse.ok) {
+ const cliData = await toResponseJson(cliResponse);
+ if (cliData.success && cliData.servers) {
+ setMcpServers(mapCliServersToMcpServers(cliData.servers));
+ return;
+ }
+ }
+
+ const fallbackResponse = await authenticatedFetch('/api/mcp/servers?scope=user');
+ if (!fallbackResponse.ok) {
+ console.error('Failed to fetch MCP servers');
+ return;
+ }
+
+ const fallbackData = await toResponseJson<{ servers?: McpServer[] }>(fallbackResponse);
+ setMcpServers(fallbackData.servers || []);
+ } catch (error) {
+ console.error('Error fetching MCP servers:', error);
+ }
+ }, []);
+
+ const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {
+ const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ const error = await toResponseJson(response);
+ throw new Error(error.error || 'Failed to delete server');
+ }
+
+ const result = await toResponseJson(response);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to delete server via Claude CLI');
+ }
+ }, []);
+
+ const saveMcpServer = useCallback(
+ async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {
+ const newServerScope = serverData.scope || 'user';
+
+ const response = await authenticatedFetch('/api/mcp/cli/add', {
+ method: 'POST',
+ body: JSON.stringify({
+ name: serverData.name,
+ type: serverData.type,
+ scope: newServerScope,
+ projectPath: serverData.projectPath,
+ command: serverData.config.command,
+ args: serverData.config.args || [],
+ url: serverData.config.url,
+ headers: serverData.config.headers || {},
+ env: serverData.config.env || {},
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await toResponseJson(response);
+ throw new Error(error.error || 'Failed to save server');
+ }
+
+ const result = await toResponseJson(response);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to save server via Claude CLI');
+ }
+
+ if (!editingServer?.id) {
+ return;
+ }
+
+ const previousServerScope = editingServer.scope || 'user';
+ const didServerIdentityChange =
+ editingServer.id !== serverData.name || previousServerScope !== newServerScope;
+
+ if (!didServerIdentityChange) {
+ return;
+ }
+
+ try {
+ await deleteMcpServer(editingServer.id, previousServerScope);
+ } catch (error) {
+ console.warn('Saved MCP server update but failed to remove the previous server entry.', {
+ previousServerId: editingServer.id,
+ previousServerScope,
+ error: getErrorMessage(error),
+ });
+ }
+ },
+ [deleteMcpServer],
+ );
+
+ const submitMcpForm = useCallback(
+ async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {
+ if (formData.importMode === 'json') {
+ const response = await authenticatedFetch('/api/mcp/cli/add-json', {
+ method: 'POST',
+ body: JSON.stringify({
+ name: formData.name,
+ jsonConfig: formData.jsonInput,
+ scope: formData.scope,
+ projectPath: formData.projectPath,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await toResponseJson(response);
+ throw new Error(error.error || 'Failed to add server');
+ }
+
+ const result = await toResponseJson(response);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to add server via JSON');
+ }
+ } else {
+ await saveMcpServer(formData, editingServer);
+ }
+
+ await fetchMcpServers();
+ setSaveStatus('success');
+ setShowMcpForm(false);
+ setEditingMcpServer(null);
+ },
+ [fetchMcpServers, saveMcpServer],
+ );
+
+ const handleMcpDelete = useCallback(
+ async (serverId: string, scope = 'user') => {
+ if (!window.confirm('Are you sure you want to delete this MCP server?')) {
+ return;
+ }
+
+ setDeleteError(null);
+ try {
+ await deleteMcpServer(serverId, scope);
+ await fetchMcpServers();
+ setDeleteError(null);
+ setSaveStatus('success');
+ } catch (error) {
+ setDeleteError(getErrorMessage(error));
+ setSaveStatus('error');
+ }
+ },
+ [deleteMcpServer, fetchMcpServers],
+ );
+
+ const testMcpServer = useCallback(async (serverId: string, scope = 'user') => {
+ const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ const error = await toResponseJson(response);
+ throw new Error(error.error || 'Failed to test server');
+ }
+
+ const data = await toResponseJson(response);
+ return data.testResult || { success: false, message: 'No test result returned' };
+ }, []);
+
+ const discoverMcpTools = useCallback(async (serverId: string, scope = 'user') => {
+ const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ const error = await toResponseJson(response);
+ throw new Error(error.error || 'Failed to discover tools');
+ }
+
+ const data = await toResponseJson(response);
+ return data.toolsResult || { success: false, tools: [], resources: [], prompts: [] };
+ }, []);
+
+ const handleMcpTest = useCallback(
+ async (serverId: string, scope = 'user') => {
+ try {
+ setMcpTestResults((prev) => ({
+ ...prev,
+ [serverId]: { success: false, message: 'Testing server...', details: [], loading: true },
+ }));
+
+ const result = await testMcpServer(serverId, scope);
+ setMcpTestResults((prev) => ({ ...prev, [serverId]: result }));
+ } catch (error) {
+ setMcpTestResults((prev) => ({
+ ...prev,
+ [serverId]: {
+ success: false,
+ message: getErrorMessage(error),
+ details: [],
+ },
+ }));
+ }
+ },
+ [testMcpServer],
+ );
+
+ const handleMcpToolsDiscovery = useCallback(
+ async (serverId: string, scope = 'user') => {
+ try {
+ setMcpToolsLoading((prev) => ({ ...prev, [serverId]: true }));
+ const result = await discoverMcpTools(serverId, scope);
+ setMcpServerTools((prev) => ({ ...prev, [serverId]: result }));
+ } catch {
+ setMcpServerTools((prev) => ({
+ ...prev,
+ [serverId]: { success: false, tools: [], resources: [], prompts: [] },
+ }));
+ } finally {
+ setMcpToolsLoading((prev) => ({ ...prev, [serverId]: false }));
+ }
+ },
+ [discoverMcpTools],
+ );
+
+ const deleteCodexMcpServer = useCallback(async (serverId: string) => {
+ const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ const error = await toResponseJson(response);
+ throw new Error(error.error || 'Failed to delete server');
+ }
+
+ const result = await toResponseJson(response);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to delete Codex MCP server');
+ }
+ }, []);
+
+ const saveCodexMcpServer = useCallback(
+ async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {
+ const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
+ method: 'POST',
+ body: JSON.stringify({
+ name: serverData.name,
+ command: serverData.config.command,
+ args: serverData.config.args || [],
+ env: serverData.config.env || {},
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await toResponseJson(response);
+ throw new Error(error.error || 'Failed to save server');
+ }
+
+ const result = await toResponseJson(response);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to save Codex MCP server');
+ }
+
+ if (!editingServer?.name || editingServer.name === serverData.name) {
+ return;
+ }
+
+ try {
+ await deleteCodexMcpServer(editingServer.name);
+ } catch (error) {
+ console.warn('Saved Codex MCP server update but failed to remove the previous server entry.', {
+ previousServerName: editingServer.name,
+ error: getErrorMessage(error),
+ });
+ }
+ },
+ [deleteCodexMcpServer],
+ );
+
+ const submitCodexMcpForm = useCallback(
+ async (formData: CodexMcpFormState, editingServer: McpServer | null) => {
+ await saveCodexMcpServer(formData, editingServer);
+ await fetchCodexMcpServers();
+ setSaveStatus('success');
+ setShowCodexMcpForm(false);
+ setEditingCodexMcpServer(null);
+ },
+ [fetchCodexMcpServers, saveCodexMcpServer],
+ );
+
+ const handleCodexMcpDelete = useCallback(
+ async (serverName: string) => {
+ if (!window.confirm('Are you sure you want to delete this MCP server?')) {
+ return;
+ }
+
+ setDeleteError(null);
+ try {
+ await deleteCodexMcpServer(serverName);
+ await fetchCodexMcpServers();
+ setDeleteError(null);
+ setSaveStatus('success');
+ } catch (error) {
+ setDeleteError(getErrorMessage(error));
+ setSaveStatus('error');
+ }
+ },
+ [deleteCodexMcpServer, fetchCodexMcpServers],
+ );
+
+ const loadSettings = useCallback(async () => {
+ try {
+ const savedClaudeSettings = parseJson(
+ localStorage.getItem('claude-settings'),
+ {},
+ );
+ setClaudePermissions({
+ allowedTools: savedClaudeSettings.allowedTools || [],
+ disallowedTools: savedClaudeSettings.disallowedTools || [],
+ skipPermissions: Boolean(savedClaudeSettings.skipPermissions),
+ });
+ setProjectSortOrder(savedClaudeSettings.projectSortOrder === 'date' ? 'date' : 'name');
+
+ const savedCursorSettings = parseJson(
+ localStorage.getItem('cursor-tools-settings'),
+ {},
+ );
+ setCursorPermissions({
+ allowedCommands: savedCursorSettings.allowedCommands || [],
+ disallowedCommands: savedCursorSettings.disallowedCommands || [],
+ skipPermissions: Boolean(savedCursorSettings.skipPermissions),
+ });
+
+ const savedCodexSettings = parseJson(
+ localStorage.getItem('codex-settings'),
+ {},
+ );
+ setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
+
+ await Promise.all([
+ fetchMcpServers(),
+ fetchCursorMcpServers(),
+ fetchCodexMcpServers(),
+ ]);
+ } catch (error) {
+ console.error('Error loading settings:', error);
+ setClaudePermissions(createEmptyClaudePermissions());
+ setCursorPermissions(createEmptyCursorPermissions());
+ setCodexPermissionMode('default');
+ setProjectSortOrder('name');
+ }
+ }, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
+
+ const openLoginForProvider = useCallback((provider: AgentProvider) => {
+ setLoginProvider(provider);
+ setSelectedProject(getDefaultProject(projects));
+ setShowLoginModal(true);
+ }, [projects]);
+
+ const handleLoginComplete = useCallback((exitCode: number) => {
+ if (exitCode !== 0 || !loginProvider) {
+ return;
+ }
+
+ setSaveStatus('success');
+ void checkAuthStatus(loginProvider);
+ }, [checkAuthStatus, loginProvider]);
+
+ const saveSettings = useCallback(() => {
+ setIsSaving(true);
+ setSaveStatus(null);
+
+ try {
+ const now = new Date().toISOString();
+ localStorage.setItem('claude-settings', JSON.stringify({
+ allowedTools: claudePermissions.allowedTools,
+ disallowedTools: claudePermissions.disallowedTools,
+ skipPermissions: claudePermissions.skipPermissions,
+ projectSortOrder,
+ lastUpdated: now,
+ }));
+
+ localStorage.setItem('cursor-tools-settings', JSON.stringify({
+ allowedCommands: cursorPermissions.allowedCommands,
+ disallowedCommands: cursorPermissions.disallowedCommands,
+ skipPermissions: cursorPermissions.skipPermissions,
+ lastUpdated: now,
+ }));
+
+ localStorage.setItem('codex-settings', JSON.stringify({
+ permissionMode: codexPermissionMode,
+ lastUpdated: now,
+ }));
+
+ setSaveStatus('success');
+ if (closeTimerRef.current !== null) {
+ window.clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }
+ closeTimerRef.current = window.setTimeout(() => onClose(), 1000);
+ } catch (error) {
+ console.error('Error saving settings:', error);
+ setSaveStatus('error');
+ } finally {
+ setIsSaving(false);
+ }
+ }, [
+ claudePermissions.allowedTools,
+ claudePermissions.disallowedTools,
+ claudePermissions.skipPermissions,
+ codexPermissionMode,
+ cursorPermissions.allowedCommands,
+ cursorPermissions.disallowedCommands,
+ cursorPermissions.skipPermissions,
+ onClose,
+ projectSortOrder,
+ ]);
+
+ const updateCodeEditorSetting = useCallback(
+ (key: K, value: CodeEditorSettingsState[K]) => {
+ setCodeEditorSettings((prev) => ({ ...prev, [key]: value }));
+ },
+ [],
+ );
+
+ const openMcpForm = useCallback((server?: McpServer) => {
+ setEditingMcpServer(server || null);
+ setShowMcpForm(true);
+ }, []);
+
+ const closeMcpForm = useCallback(() => {
+ setShowMcpForm(false);
+ setEditingMcpServer(null);
+ }, []);
+
+ const openCodexMcpForm = useCallback((server?: McpServer) => {
+ setEditingCodexMcpServer(server || null);
+ setShowCodexMcpForm(true);
+ }, []);
+
+ const closeCodexMcpForm = useCallback(() => {
+ setShowCodexMcpForm(false);
+ setEditingCodexMcpServer(null);
+ }, []);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ setActiveTab(normalizeMainTab(initialTab));
+ void loadSettings();
+ void checkAuthStatus('claude');
+ void checkAuthStatus('cursor');
+ void checkAuthStatus('codex');
+ }, [checkAuthStatus, initialTab, isOpen, loadSettings]);
+
+ useEffect(() => {
+ localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
+ localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
+ localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
+ localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
+ localStorage.setItem('codeEditorFontSize', codeEditorSettings.fontSize);
+ window.dispatchEvent(new Event('codeEditorSettingsChanged'));
+ }, [codeEditorSettings]);
+
+ useEffect(() => () => {
+ if (closeTimerRef.current !== null) {
+ window.clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }
+ }, []);
+
+ return {
+ activeTab,
+ setActiveTab,
+ isDarkMode,
+ toggleDarkMode,
+ isSaving,
+ saveStatus,
+ deleteError,
+ projectSortOrder,
+ setProjectSortOrder,
+ codeEditorSettings,
+ updateCodeEditorSetting,
+ claudePermissions,
+ setClaudePermissions,
+ cursorPermissions,
+ setCursorPermissions,
+ codexPermissionMode,
+ setCodexPermissionMode,
+ mcpServers,
+ cursorMcpServers,
+ codexMcpServers,
+ mcpTestResults,
+ mcpServerTools,
+ mcpToolsLoading,
+ showMcpForm,
+ editingMcpServer,
+ openMcpForm,
+ closeMcpForm,
+ submitMcpForm,
+ handleMcpDelete,
+ handleMcpTest,
+ handleMcpToolsDiscovery,
+ showCodexMcpForm,
+ editingCodexMcpServer,
+ openCodexMcpForm,
+ closeCodexMcpForm,
+ submitCodexMcpForm,
+ handleCodexMcpDelete,
+ claudeAuthStatus,
+ cursorAuthStatus,
+ codexAuthStatus,
+ openLoginForProvider,
+ showLoginModal,
+ setShowLoginModal,
+ loginProvider,
+ selectedProject,
+ handleLoginComplete,
+ saveSettings,
+ };
+}
diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts
new file mode 100644
index 0000000..a0071c8
--- /dev/null
+++ b/src/components/settings/types/types.ts
@@ -0,0 +1,134 @@
+import type { Dispatch, SetStateAction } from 'react';
+
+export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
+export type AgentProvider = 'claude' | 'cursor' | 'codex';
+export type AgentCategory = 'account' | 'permissions' | 'mcp';
+export type ProjectSortOrder = 'name' | 'date';
+export type SaveStatus = 'success' | 'error' | null;
+export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
+export type McpImportMode = 'form' | 'json';
+export type McpScope = 'user' | 'local';
+export type McpTransportType = 'stdio' | 'sse' | 'http';
+
+export type SettingsProject = {
+ name: string;
+ displayName?: string;
+ fullPath?: string;
+ path?: string;
+};
+
+export type AuthStatus = {
+ authenticated: boolean;
+ email: string | null;
+ loading: boolean;
+ error: string | null;
+};
+
+export type KeyValueMap = Record;
+
+export type McpServerConfig = {
+ command?: string;
+ args?: string[];
+ env?: KeyValueMap;
+ url?: string;
+ headers?: KeyValueMap;
+ timeout?: number;
+};
+
+export type McpServer = {
+ id?: string;
+ name: string;
+ type?: string;
+ scope?: string;
+ projectPath?: string;
+ config?: McpServerConfig;
+ raw?: unknown;
+ created?: string;
+ updated?: string;
+};
+
+export type ClaudeMcpFormConfig = {
+ command: string;
+ args: string[];
+ env: KeyValueMap;
+ url: string;
+ headers: KeyValueMap;
+ timeout: number;
+};
+
+export type ClaudeMcpFormState = {
+ name: string;
+ type: McpTransportType;
+ scope: McpScope;
+ projectPath: string;
+ config: ClaudeMcpFormConfig;
+ importMode: McpImportMode;
+ jsonInput: string;
+ raw?: unknown;
+};
+
+export type CodexMcpFormConfig = {
+ command: string;
+ args: string[];
+ env: KeyValueMap;
+};
+
+export type CodexMcpFormState = {
+ name: string;
+ type: 'stdio';
+ config: CodexMcpFormConfig;
+};
+
+export type McpTestResult = {
+ success: boolean;
+ message: string;
+ details?: string[];
+ loading?: boolean;
+};
+
+export type McpTool = {
+ name: string;
+ [key: string]: unknown;
+};
+
+export type McpToolsResult = {
+ success?: boolean;
+ tools?: McpTool[];
+ resources?: unknown[];
+ prompts?: unknown[];
+};
+
+export type ClaudePermissionsState = {
+ allowedTools: string[];
+ disallowedTools: string[];
+ skipPermissions: boolean;
+};
+
+export type CursorPermissionsState = {
+ allowedCommands: string[];
+ disallowedCommands: string[];
+ skipPermissions: boolean;
+};
+
+export type CodeEditorSettingsState = {
+ theme: 'dark' | 'light';
+ wordWrap: boolean;
+ showMinimap: boolean;
+ lineNumbers: boolean;
+ fontSize: string;
+};
+
+export type SettingsStoragePayload = {
+ claude: ClaudePermissionsState & { projectSortOrder: ProjectSortOrder; lastUpdated: string };
+ cursor: CursorPermissionsState & { lastUpdated: string };
+ codex: { permissionMode: CodexPermissionMode; lastUpdated: string };
+};
+
+export type SettingsProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ projects?: SettingsProject[];
+ initialTab?: string;
+};
+
+export type SetState = Dispatch>;
diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx
new file mode 100644
index 0000000..397bb77
--- /dev/null
+++ b/src/components/settings/view/Settings.tsx
@@ -0,0 +1,249 @@
+import { Settings as SettingsIcon, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import LoginModal from '../../LoginModal';
+import { Button } from '../../ui/button';
+import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
+import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
+import SettingsMainTabs from '../view/SettingsMainTabs';
+import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
+import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
+import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
+import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
+import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
+import { useSettingsController } from '../hooks/useSettingsController';
+import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
+
+type LoginModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ provider: AgentProvider | '';
+ project: SettingsProject | null;
+ onComplete: (exitCode: number) => void;
+ isAuthenticated: boolean;
+};
+
+const LoginModalComponent = LoginModal as unknown as (props: LoginModalProps) => JSX.Element;
+
+function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
+ const { t } = useTranslation('settings');
+ const {
+ activeTab,
+ setActiveTab,
+ isSaving,
+ saveStatus,
+ deleteError,
+ projectSortOrder,
+ setProjectSortOrder,
+ codeEditorSettings,
+ updateCodeEditorSetting,
+ claudePermissions,
+ setClaudePermissions,
+ cursorPermissions,
+ setCursorPermissions,
+ codexPermissionMode,
+ setCodexPermissionMode,
+ mcpServers,
+ cursorMcpServers,
+ codexMcpServers,
+ mcpTestResults,
+ mcpServerTools,
+ mcpToolsLoading,
+ showMcpForm,
+ editingMcpServer,
+ openMcpForm,
+ closeMcpForm,
+ submitMcpForm,
+ handleMcpDelete,
+ handleMcpTest,
+ handleMcpToolsDiscovery,
+ showCodexMcpForm,
+ editingCodexMcpServer,
+ openCodexMcpForm,
+ closeCodexMcpForm,
+ submitCodexMcpForm,
+ handleCodexMcpDelete,
+ claudeAuthStatus,
+ cursorAuthStatus,
+ codexAuthStatus,
+ openLoginForProvider,
+ showLoginModal,
+ setShowLoginModal,
+ loginProvider,
+ selectedProject,
+ handleLoginComplete,
+ saveSettings,
+ } = useSettingsController({
+ isOpen,
+ initialTab,
+ projects,
+ onClose,
+ });
+
+ if (!isOpen) {
+ return null;
+ }
+
+ const isAuthenticated = loginProvider === 'claude'
+ ? claudeAuthStatus.authenticated
+ : loginProvider === 'cursor'
+ ? cursorAuthStatus.authenticated
+ : loginProvider === 'codex'
+ ? codexAuthStatus.authenticated
+ : false;
+
+ return (
+
+
+
+
+
+
{t('title')}
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'appearance' && (
+
updateCodeEditorSetting('theme', value)}
+ onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
+ onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
+ onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
+ onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)}
+ />
+ )}
+
+ {activeTab === 'git' && }
+
+ {activeTab === 'agents' && (
+ openLoginForProvider('claude')}
+ onCursorLogin={() => openLoginForProvider('cursor')}
+ onCodexLogin={() => openLoginForProvider('codex')}
+ claudePermissions={claudePermissions}
+ onClaudePermissionsChange={setClaudePermissions}
+ cursorPermissions={cursorPermissions}
+ onCursorPermissionsChange={setCursorPermissions}
+ codexPermissionMode={codexPermissionMode}
+ onCodexPermissionModeChange={setCodexPermissionMode}
+ mcpServers={mcpServers}
+ cursorMcpServers={cursorMcpServers}
+ codexMcpServers={codexMcpServers}
+ mcpTestResults={mcpTestResults}
+ mcpServerTools={mcpServerTools}
+ mcpToolsLoading={mcpToolsLoading}
+ onOpenMcpForm={openMcpForm}
+ onDeleteMcpServer={handleMcpDelete}
+ onTestMcpServer={handleMcpTest}
+ onDiscoverMcpTools={handleMcpToolsDiscovery}
+ onOpenCodexMcpForm={openCodexMcpForm}
+ onDeleteCodexMcpServer={handleCodexMcpDelete}
+ deleteError={deleteError}
+ />
+ )}
+
+ {activeTab === 'tasks' && (
+
+
+
+ )}
+
+ {activeTab === 'api' && (
+
+
+
+ )}
+
+
+
+
+
+ {saveStatus === 'success' && (
+
+
+
+
+ {t('saveStatus.success')}
+
+ )}
+ {saveStatus === 'error' && (
+
+
+
+
+ {t('saveStatus.error')}
+
+ )}
+
+
+
+ {t('footerActions.cancel')}
+
+
+ {isSaving ? (
+
+
+ {t('saveStatus.saving')}
+
+ ) : (
+ t('footerActions.save')
+ )}
+
+
+
+
+
+
setShowLoginModal(false)}
+ provider={loginProvider}
+ project={selectedProject}
+ onComplete={handleLoginComplete}
+ isAuthenticated={isAuthenticated}
+ />
+
+
+
+
+
+ );
+}
+
+export default Settings;
diff --git a/src/components/settings/view/SettingsMainTabs.tsx b/src/components/settings/view/SettingsMainTabs.tsx
new file mode 100644
index 0000000..f1886f1
--- /dev/null
+++ b/src/components/settings/view/SettingsMainTabs.tsx
@@ -0,0 +1,54 @@
+import { GitBranch, Key } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { SettingsMainTab } from '../types/types';
+
+type SettingsMainTabsProps = {
+ activeTab: SettingsMainTab;
+ onChange: (tab: SettingsMainTab) => void;
+};
+
+type MainTabConfig = {
+ id: SettingsMainTab;
+ labelKey: string;
+ icon?: typeof GitBranch;
+};
+
+const TAB_CONFIG: MainTabConfig[] = [
+ { id: 'agents', labelKey: 'mainTabs.agents' },
+ { id: 'appearance', labelKey: 'mainTabs.appearance' },
+ { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
+ { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
+ { id: 'tasks', labelKey: 'mainTabs.tasks' },
+];
+
+export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
+ {TAB_CONFIG.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id;
+
+ return (
+ onChange(tab.id)}
+ className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
+ isActive
+ ? 'border-blue-600 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {Icon && }
+ {t(tab.labelKey)}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/settings/view/modals/ClaudeMcpFormModal.tsx b/src/components/settings/view/modals/ClaudeMcpFormModal.tsx
new file mode 100644
index 0000000..44e3114
--- /dev/null
+++ b/src/components/settings/view/modals/ClaudeMcpFormModal.tsx
@@ -0,0 +1,479 @@
+import { FolderOpen, Globe, X } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+import type { FormEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Input } from '../../../ui/input';
+import { Button } from '../../../ui/button';
+import { DEFAULT_CLAUDE_MCP_FORM } from '../../constants/constants';
+import type { ClaudeMcpFormState, McpServer, McpScope, McpTransportType, SettingsProject } from '../../types/types';
+
+type ClaudeMcpFormModalProps = {
+ isOpen: boolean;
+ editingServer: McpServer | null;
+ projects: SettingsProject[];
+ onClose: () => void;
+ onSubmit: (formData: ClaudeMcpFormState, editingServer: McpServer | null) => Promise;
+};
+
+const getSafeTransportType = (value: unknown): McpTransportType => {
+ if (value === 'sse' || value === 'http') {
+ return value;
+ }
+
+ return 'stdio';
+};
+
+const getSafeScope = (value: unknown): McpScope => (value === 'local' ? 'local' : 'user');
+
+const getErrorMessage = (error: unknown): string => (
+ error instanceof Error ? error.message : 'Unknown error'
+);
+
+const createFormStateFromServer = (server: McpServer): ClaudeMcpFormState => ({
+ name: server.name || '',
+ type: getSafeTransportType(server.type),
+ scope: getSafeScope(server.scope),
+ projectPath: server.projectPath || '',
+ config: {
+ command: server.config?.command || '',
+ args: server.config?.args || [],
+ env: server.config?.env || {},
+ url: server.config?.url || '',
+ headers: server.config?.headers || {},
+ timeout: server.config?.timeout || 30000,
+ },
+ importMode: 'form',
+ jsonInput: '',
+ raw: server.raw,
+});
+
+export default function ClaudeMcpFormModal({
+ isOpen,
+ editingServer,
+ projects,
+ onClose,
+ onSubmit,
+}: ClaudeMcpFormModalProps) {
+ const { t } = useTranslation('settings');
+ const [formData, setFormData] = useState(DEFAULT_CLAUDE_MCP_FORM);
+ const [jsonValidationError, setJsonValidationError] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const isEditing = Boolean(editingServer);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ setJsonValidationError('');
+ if (editingServer) {
+ setFormData(createFormStateFromServer(editingServer));
+ return;
+ }
+
+ setFormData(DEFAULT_CLAUDE_MCP_FORM);
+ }, [editingServer, isOpen]);
+
+ const canSubmit = useMemo(() => {
+ if (!formData.name.trim()) {
+ return false;
+ }
+
+ if (formData.importMode === 'json') {
+ return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
+ }
+
+ if (formData.scope === 'local' && !formData.projectPath.trim()) {
+ return false;
+ }
+
+ if (formData.type === 'stdio') {
+ return Boolean(formData.config.command.trim());
+ }
+
+ return Boolean(formData.config.url.trim());
+ }, [formData, jsonValidationError]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ const updateConfig = (
+ key: K,
+ value: ClaudeMcpFormState['config'][K],
+ ) => {
+ setFormData((prev) => ({
+ ...prev,
+ config: {
+ ...prev.config,
+ [key]: value,
+ },
+ }));
+ };
+
+ const handleJsonValidation = (value: string) => {
+ if (!value.trim()) {
+ setJsonValidationError('');
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(value) as { type?: string; command?: string; url?: string };
+ if (!parsed.type) {
+ setJsonValidationError(t('mcpForm.validation.missingType'));
+ } else if (parsed.type === 'stdio' && !parsed.command) {
+ setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
+ } else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
+ setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));
+ } else {
+ setJsonValidationError('');
+ }
+ } catch {
+ setJsonValidationError(t('mcpForm.validation.invalidJson'));
+ }
+ };
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ setIsSubmitting(true);
+
+ try {
+ await onSubmit(formData, editingServer);
+ } catch (error) {
+ alert(`Error: ${getErrorMessage(error)}`);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
+
+
+
+
+
+
+
+ {!isEditing && (
+
+ setFormData((prev) => ({ ...prev, importMode: 'form' }))}
+ className={`px-4 py-2 rounded-lg font-medium transition-colors ${
+ formData.importMode === 'form'
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
+ }`}
+ >
+ {t('mcpForm.importMode.form')}
+
+ setFormData((prev) => ({ ...prev, importMode: 'json' }))}
+ className={`px-4 py-2 rounded-lg font-medium transition-colors ${
+ formData.importMode === 'json'
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
+ }`}
+ >
+ {t('mcpForm.importMode.json')}
+
+
+ )}
+
+ {formData.importMode === 'form' && isEditing && (
+
+
+ {t('mcpForm.scope.label')}
+
+
+ {formData.scope === 'user' ? : }
+
+ {formData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}
+
+ {formData.scope === 'local' && formData.projectPath && (
+ - {formData.projectPath}
+ )}
+
+
{t('mcpForm.scope.cannotChange')}
+
+ )}
+
+ {formData.importMode === 'form' && !isEditing && (
+
+
+
+ {t('mcpForm.scope.label')} *
+
+
+
setFormData((prev) => ({ ...prev, scope: 'user', projectPath: '' }))}
+ className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
+ formData.scope === 'user'
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
+ }`}
+ >
+
+
+ {t('mcpForm.scope.userGlobal')}
+
+
+
setFormData((prev) => ({ ...prev, scope: 'local' }))}
+ className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
+ formData.scope === 'local'
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
+ }`}
+ >
+
+
+ {t('mcpForm.scope.projectLocal')}
+
+
+
+
+ {formData.scope === 'user'
+ ? t('mcpForm.scope.userDescription')
+ : t('mcpForm.scope.projectDescription')}
+
+
+
+ {formData.scope === 'local' && (
+
+
+ {t('mcpForm.fields.selectProject')} *
+
+
{
+ setFormData((prev) => ({ ...prev, projectPath: event.target.value }));
+ }}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
+ required
+ >
+ {t('mcpForm.fields.selectProject')}...
+ {projects.map((project) => (
+
+ {project.displayName || project.name}
+
+ ))}
+
+ {formData.projectPath && (
+
+ {t('mcpForm.projectPath', { path: formData.projectPath })}
+
+ )}
+
+ )}
+
+ )}
+
+
+
+
+ {t('mcpForm.fields.serverName')} *
+
+ setFormData((prev) => ({ ...prev, name: event.target.value }))}
+ placeholder={t('mcpForm.placeholders.serverName')}
+ required
+ />
+
+
+ {formData.importMode === 'form' && (
+
+
+ {t('mcpForm.fields.transportType')} *
+
+ {
+ setFormData((prev) => ({
+ ...prev,
+ type: getSafeTransportType(event.target.value),
+ }));
+ }}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
+ >
+ stdio
+ SSE
+ HTTP
+
+
+ )}
+
+
+ {isEditing && Boolean(formData.raw) && formData.importMode === 'form' && (
+
+
+ {t('mcpForm.configDetails', {
+ configFile: editingServer?.scope === 'global' ? '~/.claude.json' : 'project config',
+ })}
+
+
+ {JSON.stringify(formData.raw, null, 2)}
+
+
+ )}
+
+ {formData.importMode === 'json' && (
+
+
+
+ {t('mcpForm.fields.jsonConfig')} *
+
+
{
+ const value = event.target.value;
+ setFormData((prev) => ({ ...prev, jsonInput: value }));
+ handleJsonValidation(value);
+ }}
+ className={`w-full px-3 py-2 border ${
+ jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
+ } bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 font-mono text-sm`}
+ rows={8}
+ placeholder={'{\n "type": "stdio",\n "command": "/path/to/server",\n "args": ["--api-key", "abc123"],\n "env": {\n "CACHE_DIR": "/tmp"\n }\n}'}
+ required
+ />
+ {jsonValidationError && (
+ {jsonValidationError}
+ )}
+
+ {t('mcpForm.validation.jsonHelp')}
+
+ - stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
+
+ - http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
+
+
+
+ )}
+
+ {formData.importMode === 'form' && formData.type === 'stdio' && (
+
+
+
+ {t('mcpForm.fields.command')} *
+
+ updateConfig('command', event.target.value)}
+ placeholder="/path/to/mcp-server"
+ required
+ />
+
+
+
+
+ {t('mcpForm.fields.arguments')}
+
+ {
+ const args = event.target.value.split('\n').filter((arg) => arg.trim());
+ updateConfig('args', args);
+ }}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
+ rows={3}
+ placeholder="--api-key
abc123"
+ />
+
+
+ )}
+
+ {formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
+
+
+ {t('mcpForm.fields.url')} *
+
+ updateConfig('url', event.target.value)}
+ placeholder="https://api.example.com/mcp"
+ type="url"
+ required
+ />
+
+ )}
+
+ {formData.importMode === 'form' && (
+
+
+ {t('mcpForm.fields.envVars')}
+
+ `${key}=${value}`).join('\n')}
+ onChange={(event) => {
+ const env: Record = {};
+ event.target.value.split('\n').forEach((line) => {
+ const [key, ...valueParts] = line.split('=');
+ if (key && key.trim()) {
+ env[key.trim()] = valueParts.join('=').trim();
+ }
+ });
+ updateConfig('env', env);
+ }}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
+ rows={3}
+ placeholder="API_KEY=your-key
DEBUG=true"
+ />
+
+ )}
+
+ {formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
+
+
+ {t('mcpForm.fields.headers')}
+
+ `${key}=${value}`).join('\n')}
+ onChange={(event) => {
+ const headers: Record = {};
+ event.target.value.split('\n').forEach((line) => {
+ const [key, ...valueParts] = line.split('=');
+ if (key && key.trim()) {
+ headers[key.trim()] = valueParts.join('=').trim();
+ }
+ });
+ updateConfig('headers', headers);
+ }}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
+ rows={3}
+ placeholder="Authorization=Bearer token
X-API-Key=your-key"
+ />
+
+ )}
+
+
+
+ {t('mcpForm.actions.cancel')}
+
+
+ {isSubmitting
+ ? t('mcpForm.actions.saving')
+ : isEditing
+ ? t('mcpForm.actions.updateServer')
+ : t('mcpForm.actions.addServer')}
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/view/modals/CodexMcpFormModal.tsx b/src/components/settings/view/modals/CodexMcpFormModal.tsx
new file mode 100644
index 0000000..6e34389
--- /dev/null
+++ b/src/components/settings/view/modals/CodexMcpFormModal.tsx
@@ -0,0 +1,178 @@
+import { useEffect, useState } from 'react';
+import type { FormEvent } from 'react';
+import { X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '../../../ui/button';
+import { Input } from '../../../ui/input';
+import { DEFAULT_CODEX_MCP_FORM } from '../../constants/constants';
+import type { CodexMcpFormState, McpServer } from '../../types/types';
+
+type CodexMcpFormModalProps = {
+ isOpen: boolean;
+ editingServer: McpServer | null;
+ onClose: () => void;
+ onSubmit: (formData: CodexMcpFormState, editingServer: McpServer | null) => Promise;
+};
+
+const getErrorMessage = (error: unknown): string => (
+ error instanceof Error ? error.message : 'Unknown error'
+);
+
+const createFormStateFromServer = (server: McpServer): CodexMcpFormState => ({
+ name: server.name || '',
+ type: 'stdio',
+ config: {
+ command: server.config?.command || '',
+ args: server.config?.args || [],
+ env: server.config?.env || {},
+ },
+});
+
+export default function CodexMcpFormModal({
+ isOpen,
+ editingServer,
+ onClose,
+ onSubmit,
+}: CodexMcpFormModalProps) {
+ const { t } = useTranslation('settings');
+ const [formData, setFormData] = useState(DEFAULT_CODEX_MCP_FORM);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ if (editingServer) {
+ setFormData(createFormStateFromServer(editingServer));
+ return;
+ }
+
+ setFormData(DEFAULT_CODEX_MCP_FORM);
+ }, [editingServer, isOpen]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ setIsSubmitting(true);
+
+ try {
+ await onSubmit(formData, editingServer);
+ } catch (error) {
+ alert(`Error: ${getErrorMessage(error)}`);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {editingServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
+
+
+
+
+
+
+
+
+
+ {t('mcpForm.fields.serverName')} *
+
+ setFormData((prev) => ({ ...prev, name: event.target.value }))}
+ placeholder={t('mcpForm.placeholders.serverName')}
+ required
+ />
+
+
+
+
+ {t('mcpForm.fields.command')} *
+
+ {
+ const command = event.target.value;
+ setFormData((prev) => ({
+ ...prev,
+ config: { ...prev.config, command },
+ }));
+ }}
+ placeholder="npx @my-org/mcp-server"
+ required
+ />
+
+
+
+
+ {t('mcpForm.fields.arguments')}
+
+ {
+ const args = event.target.value.split('\n').filter((arg) => arg.trim());
+ setFormData((prev) => ({
+ ...prev,
+ config: { ...prev.config, args },
+ }));
+ }}
+ placeholder="--port
3000"
+ rows={3}
+ className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+
+
+
+ {t('mcpForm.fields.envVars')}
+
+ `${key}=${value}`).join('\n')}
+ onChange={(event) => {
+ const env: Record = {};
+ event.target.value.split('\n').forEach((line) => {
+ const [key, ...valueParts] = line.split('=');
+ if (key && valueParts.length > 0) {
+ env[key.trim()] = valueParts.join('=').trim();
+ }
+ });
+ setFormData((prev) => ({
+ ...prev,
+ config: { ...prev.config, env },
+ }));
+ }}
+ placeholder="API_KEY=xxx
DEBUG=true"
+ rows={3}
+ className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+
+
+
+ {t('mcpForm.actions.cancel')}
+
+
+ {isSubmitting
+ ? t('mcpForm.actions.saving')
+ : editingServer
+ ? t('mcpForm.actions.updateServer')
+ : t('mcpForm.actions.addServer')}
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/view/tabs/AppearanceSettingsTab.tsx b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx
new file mode 100644
index 0000000..b014611
--- /dev/null
+++ b/src/components/settings/view/tabs/AppearanceSettingsTab.tsx
@@ -0,0 +1,193 @@
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import DarkModeToggle from '../../../DarkModeToggle';
+import LanguageSelector from '../../../LanguageSelector';
+import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types';
+
+type AppearanceSettingsTabProps = {
+ projectSortOrder: ProjectSortOrder;
+ onProjectSortOrderChange: (value: ProjectSortOrder) => void;
+ codeEditorSettings: CodeEditorSettingsState;
+ onCodeEditorThemeChange: (value: 'dark' | 'light') => void;
+ onCodeEditorWordWrapChange: (value: boolean) => void;
+ onCodeEditorShowMinimapChange: (value: boolean) => void;
+ onCodeEditorLineNumbersChange: (value: boolean) => void;
+ onCodeEditorFontSizeChange: (value: string) => void;
+};
+
+type ToggleCardProps = {
+ label: string;
+ description: string;
+ checked: boolean;
+ onChange: (value: boolean) => void;
+ onIcon?: ReactNode;
+ offIcon?: ReactNode;
+ ariaLabel: string;
+};
+
+function ToggleCard({
+ label,
+ description,
+ checked,
+ onChange,
+ onIcon,
+ offIcon,
+ ariaLabel,
+}: ToggleCardProps) {
+ return (
+
+
+
+
{label}
+
{description}
+
+
onChange(!checked)}
+ className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
+ role="switch"
+ aria-checked={checked}
+ aria-label={ariaLabel}
+ >
+ {ariaLabel}
+
+ {checked ? onIcon : offIcon}
+
+
+
+
+ );
+}
+
+export default function AppearanceSettingsTab({
+ projectSortOrder,
+ onProjectSortOrderChange,
+ codeEditorSettings,
+ onCodeEditorThemeChange,
+ onCodeEditorWordWrapChange,
+ onCodeEditorShowMinimapChange,
+ onCodeEditorLineNumbersChange,
+ onCodeEditorFontSizeChange,
+}: AppearanceSettingsTabProps) {
+ const { t } = useTranslation('settings');
+ const codeEditorThemeLabel = t('appearanceSettings.codeEditor.theme.label');
+
+ return (
+
+
+
+
+
+
{t('appearanceSettings.darkMode.label')}
+
+ {t('appearanceSettings.darkMode.description')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('appearanceSettings.projectSorting.label')}
+
+
+ {t('appearanceSettings.projectSorting.description')}
+
+
+
onProjectSortOrderChange(event.target.value as ProjectSortOrder)}
+ className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-32"
+ >
+ {t('appearanceSettings.projectSorting.alphabetical')}
+ {t('appearanceSettings.projectSorting.recentActivity')}
+
+
+
+
+
+
+
{t('appearanceSettings.codeEditor.title')}
+
+
+
+
+
{codeEditorThemeLabel}
+
+ {t('appearanceSettings.codeEditor.theme.description')}
+
+
+
onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
+ ariaLabel={codeEditorThemeLabel}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('appearanceSettings.codeEditor.fontSize.label')}
+
+
+ {t('appearanceSettings.codeEditor.fontSize.description')}
+
+
+
onCodeEditorFontSizeChange(event.target.value)}
+ className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-24"
+ >
+ 10px
+ 11px
+ 12px
+ 13px
+ 14px
+ 15px
+ 16px
+ 18px
+ 20px
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/AgentListItem.jsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
similarity index 79%
rename from src/components/settings/AgentListItem.jsx
rename to src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
index babd8b5..21f07cc 100644
--- a/src/components/settings/AgentListItem.jsx
+++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx
@@ -1,7 +1,21 @@
-import SessionProviderLogo from '../SessionProviderLogo';
import { useTranslation } from 'react-i18next';
+import SessionProviderLogo from '../../../../llm-logo-provider/SessionProviderLogo';
+import type { AgentProvider, AuthStatus } from '../../../types/types';
-const agentConfig = {
+type AgentListItemProps = {
+ agentId: AgentProvider;
+ authStatus: AuthStatus;
+ isSelected: boolean;
+ onClick: () => void;
+ isMobile?: boolean;
+};
+
+type AgentConfig = {
+ name: string;
+ color: 'blue' | 'purple' | 'gray';
+};
+
+const agentConfig: Record = {
claude: {
name: 'Claude',
color: 'blue',
@@ -35,14 +49,19 @@ const colorClasses = {
bg: 'bg-gray-100 dark:bg-gray-800/50',
dot: 'bg-gray-700 dark:bg-gray-300',
},
-};
+} as const;
-export default function AgentListItem({ agentId, authStatus, isSelected, onClick, isMobile = false }) {
+export default function AgentListItem({
+ agentId,
+ authStatus,
+ isSelected,
+ onClick,
+ isMobile = false,
+}: AgentListItemProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agentId];
const colors = colorClasses[config.color];
- // Mobile: horizontal layout with bottom border
if (isMobile) {
return (
{config.name}
- {authStatus?.authenticated && (
+ {authStatus.authenticated && (
)}
@@ -64,7 +83,6 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
);
}
- // Desktop: vertical layout with left border
return (
{config.name}
- {authStatus?.loading ? (
+ {authStatus.loading ? (
{t('agents.authStatus.checking')}
- ) : authStatus?.authenticated ? (
+ ) : authStatus.authenticated ? (
-
+
{authStatus.email || t('agents.authStatus.connected')}
diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
new file mode 100644
index 0000000..446663a
--- /dev/null
+++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx
@@ -0,0 +1,101 @@
+import { useMemo, useState } from 'react';
+import type { AgentCategory, AgentProvider } from '../../../types/types';
+import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
+import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
+import AgentSelectorSection from './sections/AgentSelectorSection';
+import type { AgentContext, AgentsSettingsTabProps } from './types';
+
+export default function AgentsSettingsTab({
+ claudeAuthStatus,
+ cursorAuthStatus,
+ codexAuthStatus,
+ onClaudeLogin,
+ onCursorLogin,
+ onCodexLogin,
+ claudePermissions,
+ onClaudePermissionsChange,
+ cursorPermissions,
+ onCursorPermissionsChange,
+ codexPermissionMode,
+ onCodexPermissionModeChange,
+ mcpServers,
+ cursorMcpServers,
+ codexMcpServers,
+ mcpTestResults,
+ mcpServerTools,
+ mcpToolsLoading,
+ deleteError,
+ onOpenMcpForm,
+ onDeleteMcpServer,
+ onTestMcpServer,
+ onDiscoverMcpTools,
+ onOpenCodexMcpForm,
+ onDeleteCodexMcpServer,
+}: AgentsSettingsTabProps) {
+ const [selectedAgent, setSelectedAgent] = useState
('claude');
+ const [selectedCategory, setSelectedCategory] = useState('account');
+
+ const agentContextById = useMemo>(() => ({
+ claude: {
+ authStatus: claudeAuthStatus,
+ onLogin: onClaudeLogin,
+ },
+ cursor: {
+ authStatus: cursorAuthStatus,
+ onLogin: onCursorLogin,
+ },
+ codex: {
+ authStatus: codexAuthStatus,
+ onLogin: onCodexLogin,
+ },
+ }), [
+ claudeAuthStatus,
+ codexAuthStatus,
+ cursorAuthStatus,
+ onClaudeLogin,
+ onCodexLogin,
+ onCursorLogin,
+ ]);
+
+ return (
+
+ );
+}
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx
new file mode 100644
index 0000000..f8a577b
--- /dev/null
+++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx
@@ -0,0 +1,125 @@
+import AccountContent from './content/AccountContent';
+import McpServersContent from './content/McpServersContent';
+import PermissionsContent from './content/PermissionsContent';
+import type { AgentCategoryContentSectionProps } from '../types';
+
+export default function AgentCategoryContentSection({
+ selectedAgent,
+ selectedCategory,
+ agentContextById,
+ claudePermissions,
+ onClaudePermissionsChange,
+ cursorPermissions,
+ onCursorPermissionsChange,
+ codexPermissionMode,
+ onCodexPermissionModeChange,
+ mcpServers,
+ cursorMcpServers,
+ codexMcpServers,
+ mcpTestResults,
+ mcpServerTools,
+ mcpToolsLoading,
+ deleteError,
+ onOpenMcpForm,
+ onDeleteMcpServer,
+ onTestMcpServer,
+ onDiscoverMcpTools,
+ onOpenCodexMcpForm,
+ onDeleteCodexMcpServer,
+}: AgentCategoryContentSectionProps) {
+ // Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
+ const noopCursorMcpAction = () => {};
+
+ return (
+
+ {selectedCategory === 'account' && (
+
+ )}
+
+ {selectedCategory === 'permissions' && selectedAgent === 'claude' && (
+
{
+ onClaudePermissionsChange({ ...claudePermissions, skipPermissions: value });
+ }}
+ allowedTools={claudePermissions.allowedTools}
+ onAllowedToolsChange={(value) => {
+ onClaudePermissionsChange({ ...claudePermissions, allowedTools: value });
+ }}
+ disallowedTools={claudePermissions.disallowedTools}
+ onDisallowedToolsChange={(value) => {
+ onClaudePermissionsChange({ ...claudePermissions, disallowedTools: value });
+ }}
+ />
+ )}
+
+ {selectedCategory === 'permissions' && selectedAgent === 'cursor' && (
+ {
+ onCursorPermissionsChange({ ...cursorPermissions, skipPermissions: value });
+ }}
+ allowedCommands={cursorPermissions.allowedCommands}
+ onAllowedCommandsChange={(value) => {
+ onCursorPermissionsChange({ ...cursorPermissions, allowedCommands: value });
+ }}
+ disallowedCommands={cursorPermissions.disallowedCommands}
+ onDisallowedCommandsChange={(value) => {
+ onCursorPermissionsChange({ ...cursorPermissions, disallowedCommands: value });
+ }}
+ />
+ )}
+
+ {selectedCategory === 'permissions' && selectedAgent === 'codex' && (
+
+ )}
+
+ {selectedCategory === 'mcp' && selectedAgent === 'claude' && (
+ onOpenMcpForm()}
+ onEdit={(server) => onOpenMcpForm(server)}
+ onDelete={onDeleteMcpServer}
+ onTest={onTestMcpServer}
+ onDiscoverTools={onDiscoverMcpTools}
+ testResults={mcpTestResults}
+ serverTools={mcpServerTools}
+ toolsLoading={mcpToolsLoading}
+ deleteError={deleteError}
+ />
+ )}
+
+ {selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
+
+ )}
+
+ {selectedCategory === 'mcp' && selectedAgent === 'codex' && (
+ onOpenCodexMcpForm()}
+ onEdit={(server) => onOpenCodexMcpForm(server)}
+ onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}
+ deleteError={deleteError}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx
new file mode 100644
index 0000000..e0ea1b5
--- /dev/null
+++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx
@@ -0,0 +1,36 @@
+import { useTranslation } from 'react-i18next';
+import type { AgentCategory } from '../../../../types/types';
+import type { AgentCategoryTabsSectionProps } from '../types';
+
+const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
+
+export default function AgentCategoryTabsSection({
+ selectedCategory,
+ onSelectCategory,
+}: AgentCategoryTabsSectionProps) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
+ {AGENT_CATEGORIES.map((category) => (
+ onSelectCategory(category)}
+ className={`px-3 md:px-4 py-2 md:py-3 text-xs md:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
+ selectedCategory === category
+ ? 'border-blue-600 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-muted-foreground hover:text-foreground'
+ }`}
+ >
+ {category === 'account' && t('tabs.account')}
+ {category === 'permissions' && t('tabs.permissions')}
+ {category === 'mcp' && t('tabs.mcpServers')}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
new file mode 100644
index 0000000..79559e2
--- /dev/null
+++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx
@@ -0,0 +1,44 @@
+import type { AgentProvider } from '../../../../types/types';
+import AgentListItem from '../AgentListItem';
+import type { AgentSelectorSectionProps } from '../types';
+
+const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
+
+export default function AgentSelectorSection({
+ selectedAgent,
+ onSelectAgent,
+ agentContextById,
+}: AgentSelectorSectionProps) {
+ return (
+ <>
+
+
+ {AGENT_PROVIDERS.map((agent) => (
+
onSelectAgent(agent)}
+ isMobile
+ />
+ ))}
+
+
+
+
+
+ {AGENT_PROVIDERS.map((agent) => (
+
onSelectAgent(agent)}
+ />
+ ))}
+
+
+ >
+ );
+}
diff --git a/src/components/settings/AccountContent.jsx b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx
similarity index 73%
rename from src/components/settings/AccountContent.jsx
rename to src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx
index cad2e2a..cd00ee8 100644
--- a/src/components/settings/AccountContent.jsx
+++ b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx
@@ -1,13 +1,28 @@
-import { Button } from '../ui/button';
-import { Badge } from '../ui/badge';
import { LogIn } from 'lucide-react';
-import SessionProviderLogo from '../SessionProviderLogo';
import { useTranslation } from 'react-i18next';
+import { Badge } from '../../../../../../ui/badge';
+import { Button } from '../../../../../../ui/button';
+import SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';
+import type { AgentProvider, AuthStatus } from '../../../../../types/types';
-const agentConfig = {
+type AccountContentProps = {
+ agent: AgentProvider;
+ authStatus: AuthStatus;
+ onLogin: () => void;
+};
+
+type AgentVisualConfig = {
+ name: string;
+ bgClass: string;
+ borderClass: string;
+ textClass: string;
+ subtextClass: string;
+ buttonClass: string;
+};
+
+const agentConfig: Record = {
claude: {
name: 'Claude',
- description: 'Anthropic Claude AI assistant',
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
borderClass: 'border-blue-200 dark:border-blue-800',
textClass: 'text-blue-900 dark:text-blue-100',
@@ -16,7 +31,6 @@ const agentConfig = {
},
cursor: {
name: 'Cursor',
- description: 'Cursor AI-powered code editor',
bgClass: 'bg-purple-50 dark:bg-purple-900/20',
borderClass: 'border-purple-200 dark:border-purple-800',
textClass: 'text-purple-900 dark:text-purple-100',
@@ -25,7 +39,6 @@ const agentConfig = {
},
codex: {
name: 'Codex',
- description: 'OpenAI Codex AI assistant',
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
borderClass: 'border-gray-300 dark:border-gray-600',
textClass: 'text-gray-900 dark:text-gray-100',
@@ -34,7 +47,7 @@ const agentConfig = {
},
};
-export default function AccountContent({ agent, authStatus, onLogin }) {
+export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
@@ -50,29 +63,30 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
- {/* Connection Status */}
{t('agents.connectionStatus')}
- {authStatus?.loading ? (
+ {authStatus.loading ? (
t('agents.authStatus.checkingAuth')
- ) : authStatus?.authenticated ? (
- t('agents.authStatus.loggedInAs', { email: authStatus.email || t('agents.authStatus.authenticatedUser') })
+ ) : authStatus.authenticated ? (
+ t('agents.authStatus.loggedInAs', {
+ email: authStatus.email || t('agents.authStatus.authenticatedUser'),
+ })
) : (
t('agents.authStatus.notConnected')
)}
- {authStatus?.loading ? (
+ {authStatus.loading ? (
{t('agents.authStatus.checking')}
- ) : authStatus?.authenticated ? (
-
+ ) : authStatus.authenticated ? (
+
{t('agents.authStatus.connected')}
) : (
@@ -87,10 +101,10 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
- {authStatus?.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
+ {authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
- {authStatus?.authenticated
+ {authStatus.authenticated
? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })}
@@ -101,12 +115,12 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
size="sm"
>
- {authStatus?.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
+ {authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
- {authStatus?.error && (
+ {authStatus.error && (
{t('agents.error', { error: authStatus.error })}
diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx
new file mode 100644
index 0000000..52ce9ff
--- /dev/null
+++ b/src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx
@@ -0,0 +1,382 @@
+import { Edit3, Globe, Plus, Server, Terminal, Trash2, Zap } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Badge } from '../../../../../../ui/badge';
+import { Button } from '../../../../../../ui/button';
+import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';
+
+const getTransportIcon = (type: string | undefined) => {
+ if (type === 'stdio') {
+ return
;
+ }
+
+ if (type === 'sse') {
+ return
;
+ }
+
+ if (type === 'http') {
+ return
;
+ }
+
+ return
;
+};
+
+const maskSecret = (value: unknown): string => {
+ const normalizedValue = String(value ?? '');
+ if (normalizedValue.length <= 4) {
+ return '****';
+ }
+
+ return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
+};
+
+type ClaudeMcpServersProps = {
+ agent: 'claude';
+ servers: McpServer[];
+ onAdd: () => void;
+ onEdit: (server: McpServer) => void;
+ onDelete: (serverId: string, scope?: string) => void;
+ onTest: (serverId: string, scope?: string) => void;
+ onDiscoverTools: (serverId: string, scope?: string) => void;
+ testResults: Record
;
+ serverTools: Record;
+ toolsLoading: Record;
+ deleteError?: string | null;
+};
+
+function ClaudeMcpServers({
+ servers,
+ onAdd,
+ onEdit,
+ onDelete,
+ testResults,
+ serverTools,
+ deleteError,
+}: Omit) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
+
+
{t('mcpServers.title')}
+
+
{t('mcpServers.description.claude')}
+
+
+
+
+ {t('mcpServers.addButton')}
+
+
+ {deleteError && (
+
+ {deleteError}
+
+ )}
+
+
+ {servers.map((server) => {
+ const serverId = server.id || server.name;
+ const testResult = testResults[serverId];
+ const toolsResult = serverTools[serverId];
+
+ return (
+
+
+
+
+ {getTransportIcon(server.type)}
+ {server.name}
+
+ {server.type || 'stdio'}
+
+
+ {server.scope === 'local'
+ ? t('mcpServers.scope.local')
+ : server.scope === 'user'
+ ? t('mcpServers.scope.user')
+ : server.scope}
+
+
+
+
+ {server.type === 'stdio' && server.config?.command && (
+
+ {t('mcpServers.config.command')}:{' '}
+ {server.config.command}
+
+ )}
+ {(server.type === 'sse' || server.type === 'http') && server.config?.url && (
+
+ {t('mcpServers.config.url')}:{' '}
+ {server.config.url}
+
+ )}
+ {server.config?.args && server.config.args.length > 0 && (
+
+ {t('mcpServers.config.args')}:{' '}
+ {server.config.args.join(' ')}
+
+ )}
+
+
+ {testResult && (
+
+ )}
+
+ {toolsResult && toolsResult.tools && toolsResult.tools.length > 0 && (
+
+
+ {t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: toolsResult.tools.length })}
+
+
+ {toolsResult.tools.slice(0, 5).map((tool, index) => (
+
+ {tool.name}
+
+ ))}
+ {toolsResult.tools.length > 5 && (
+
+ {t('mcpServers.tools.more', { count: toolsResult.tools.length - 5 })}
+
+ )}
+
+
+ )}
+
+
+
+ onEdit(server)}
+ variant="ghost"
+ size="sm"
+ className="text-gray-600 hover:text-gray-700"
+ title={t('mcpServers.actions.edit')}
+ >
+
+
+ onDelete(serverId, server.scope)}
+ variant="ghost"
+ size="sm"
+ className="text-red-600 hover:text-red-700"
+ title={t('mcpServers.actions.delete')}
+ >
+
+
+
+
+
+ );
+ })}
+ {servers.length === 0 && (
+
{t('mcpServers.empty')}
+ )}
+
+
+ );
+}
+
+type CursorMcpServersProps = {
+ agent: 'cursor';
+ servers: McpServer[];
+ onAdd: () => void;
+ onEdit: (server: McpServer) => void;
+ onDelete: (serverId: string) => void;
+};
+
+function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
+
+
{t('mcpServers.title')}
+
+
{t('mcpServers.description.cursor')}
+
+
+
+
+ {t('mcpServers.addButton')}
+
+
+
+
+ {servers.map((server) => {
+ const serverId = server.id || server.name;
+
+ return (
+
+
+
+
+
+ {server.name}
+ stdio
+
+
+ {server.config?.command && (
+
+ {t('mcpServers.config.command')}:{' '}
+ {server.config.command}
+
+ )}
+
+
+
+ onEdit(server)}
+ variant="ghost"
+ size="sm"
+ className="text-gray-600 hover:text-gray-700"
+ title={t('mcpServers.actions.edit')}
+ >
+
+
+ onDelete(serverId)}
+ variant="ghost"
+ size="sm"
+ className="text-red-600 hover:text-red-700"
+ title={t('mcpServers.actions.delete')}
+ >
+
+
+
+
+
+ );
+ })}
+ {servers.length === 0 && (
+
{t('mcpServers.empty')}
+ )}
+
+
+ );
+}
+
+type CodexMcpServersProps = {
+ agent: 'codex';
+ servers: McpServer[];
+ onAdd: () => void;
+ onEdit: (server: McpServer) => void;
+ onDelete: (serverId: string) => void;
+ deleteError?: string | null;
+};
+
+function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
+
+
{t('mcpServers.title')}
+
+
{t('mcpServers.description.codex')}
+
+
+
+
+ {t('mcpServers.addButton')}
+
+
+ {deleteError && (
+
+ {deleteError}
+
+ )}
+
+
+ {servers.map((server) => (
+
+
+
+
+
+ {server.name}
+ stdio
+
+
+
+ {server.config?.command && (
+
+ {t('mcpServers.config.command')}:{' '}
+ {server.config.command}
+
+ )}
+ {server.config?.args && server.config.args.length > 0 && (
+
+ {t('mcpServers.config.args')}:{' '}
+ {server.config.args.join(' ')}
+
+ )}
+ {server.config?.env && Object.keys(server.config.env).length > 0 && (
+
+ {t('mcpServers.config.environment')}:{' '}
+
+ {Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
+
+
+ )}
+
+
+
+
+ onEdit(server)}
+ variant="ghost"
+ size="sm"
+ className="text-gray-600 hover:text-gray-700"
+ title={t('mcpServers.actions.edit')}
+ >
+
+
+ onDelete(server.name)}
+ variant="ghost"
+ size="sm"
+ className="text-red-600 hover:text-red-700"
+ title={t('mcpServers.actions.delete')}
+ >
+
+
+
+
+
+ ))}
+ {servers.length === 0 && (
+
{t('mcpServers.empty')}
+ )}
+
+
+
+
{t('mcpServers.help.title')}
+
{t('mcpServers.help.description')}
+
+
+ );
+}
+
+type McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;
+
+export default function McpServersContent(props: McpServersContentProps) {
+ if (props.agent === 'claude') {
+ return ;
+ }
+
+ if (props.agent === 'cursor') {
+ return ;
+ }
+
+ return ;
+}
diff --git a/src/components/settings/PermissionsContent.jsx b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx
similarity index 66%
rename from src/components/settings/PermissionsContent.jsx
rename to src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx
index 7bbd53d..10f77be 100644
--- a/src/components/settings/PermissionsContent.jsx
+++ b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx
@@ -1,10 +1,11 @@
-import { Button } from '../ui/button';
-import { Input } from '../ui/input';
-import { Shield, AlertTriangle, Plus, X } from 'lucide-react';
+import { useState } from 'react';
+import { AlertTriangle, Plus, Shield, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
+import { Button } from '../../../../../../ui/button';
+import { Input } from '../../../../../../ui/input';
+import type { CodexPermissionMode } from '../../../../../types/types';
-// Common tool patterns for Claude
-const commonClaudeTools = [
+const COMMON_CLAUDE_TOOLS = [
'Bash(git log:*)',
'Bash(git diff:*)',
'Bash(git status:*)',
@@ -18,11 +19,10 @@ const commonClaudeTools = [
'TodoWrite',
'TodoRead',
'WebFetch',
- 'WebSearch'
+ 'WebSearch',
];
-// Common shell commands for Cursor
-const commonCursorCommands = [
+const COMMON_CURSOR_COMMANDS = [
'Shell(ls)',
'Shell(mkdir)',
'Shell(cd)',
@@ -34,61 +34,77 @@ const commonCursorCommands = [
'Shell(npm install)',
'Shell(npm run)',
'Shell(python)',
- 'Shell(node)'
+ 'Shell(node)',
];
-// Claude Permissions
+const addUnique = (items: string[], value: string): string[] => {
+ const normalizedValue = value.trim();
+ if (!normalizedValue || items.includes(normalizedValue)) {
+ return items;
+ }
+
+ return [...items, normalizedValue];
+};
+
+const removeValue = (items: string[], value: string): string[] => (
+ items.filter((item) => item !== value)
+);
+
+type ClaudePermissionsProps = {
+ agent: 'claude';
+ skipPermissions: boolean;
+ onSkipPermissionsChange: (value: boolean) => void;
+ allowedTools: string[];
+ onAllowedToolsChange: (value: string[]) => void;
+ disallowedTools: string[];
+ onDisallowedToolsChange: (value: string[]) => void;
+};
+
function ClaudePermissions({
skipPermissions,
- setSkipPermissions,
+ onSkipPermissionsChange,
allowedTools,
- setAllowedTools,
+ onAllowedToolsChange,
disallowedTools,
- setDisallowedTools,
- newAllowedTool,
- setNewAllowedTool,
- newDisallowedTool,
- setNewDisallowedTool,
-}) {
+ onDisallowedToolsChange,
+}: Omit) {
const { t } = useTranslation('settings');
- const addAllowedTool = (tool) => {
- if (tool && !allowedTools.includes(tool)) {
- setAllowedTools([...allowedTools, tool]);
- setNewAllowedTool('');
+ const [newAllowedTool, setNewAllowedTool] = useState('');
+ const [newDisallowedTool, setNewDisallowedTool] = useState('');
+
+ const handleAddAllowedTool = (tool: string) => {
+ const updated = addUnique(allowedTools, tool);
+ if (updated.length === allowedTools.length) {
+ return;
}
+
+ onAllowedToolsChange(updated);
+ setNewAllowedTool('');
};
- const removeAllowedTool = (tool) => {
- setAllowedTools(allowedTools.filter(t => t !== tool));
- };
-
- const addDisallowedTool = (tool) => {
- if (tool && !disallowedTools.includes(tool)) {
- setDisallowedTools([...disallowedTools, tool]);
- setNewDisallowedTool('');
+ const handleAddDisallowedTool = (tool: string) => {
+ const updated = addUnique(disallowedTools, tool);
+ if (updated.length === disallowedTools.length) {
+ return;
}
- };
- const removeDisallowedTool = (tool) => {
- setDisallowedTools(disallowedTools.filter(t => t !== tool));
+ onDisallowedToolsChange(updated);
+ setNewDisallowedTool('');
};
return (
- {/* Skip Permissions */}
-
- {t('permissions.title')}
-
+
{t('permissions.title')}
- {/* Allowed Tools */}
-
- {t('permissions.allowedTools.title')}
-
+ {t('permissions.allowedTools.title')}
-
- {t('permissions.allowedTools.description')}
-
+
{t('permissions.allowedTools.description')}
setNewAllowedTool(e.target.value)}
+ onChange={(event) => setNewAllowedTool(event.target.value)}
placeholder={t('permissions.allowedTools.placeholder')}
- onKeyPress={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addAllowedTool(newAllowedTool);
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleAddAllowedTool(newAllowedTool);
}
}}
className="flex-1 h-10"
/>
addAllowedTool(newAllowedTool)}
- disabled={!newAllowedTool}
+ onClick={() => handleAddAllowedTool(newAllowedTool)}
+ disabled={!newAllowedTool.trim()}
size="sm"
className="h-10 px-4"
>
@@ -139,18 +150,17 @@ function ClaudePermissions({
- {/* Quick add buttons */}
{t('permissions.allowedTools.quickAdd')}
- {commonClaudeTools.map(tool => (
+ {COMMON_CLAUDE_TOOLS.map((tool) => (
addAllowedTool(tool)}
+ onClick={() => handleAddAllowedTool(tool)}
disabled={allowedTools.includes(tool)}
className="text-xs h-8"
>
@@ -161,15 +171,13 @@ function ClaudePermissions({
- {allowedTools.map(tool => (
+ {allowedTools.map((tool) => (
-
- {tool}
-
+ {tool}
removeAllowedTool(tool)}
+ onClick={() => onAllowedToolsChange(removeValue(allowedTools, tool))}
className="text-green-600 hover:text-green-700"
>
@@ -184,34 +192,29 @@ function ClaudePermissions({
- {/* Disallowed Tools */}
-
- {t('permissions.blockedTools.title')}
-
+
{t('permissions.blockedTools.title')}
-
- {t('permissions.blockedTools.description')}
-
+
{t('permissions.blockedTools.description')}
setNewDisallowedTool(e.target.value)}
+ onChange={(event) => setNewDisallowedTool(event.target.value)}
placeholder={t('permissions.blockedTools.placeholder')}
- onKeyPress={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addDisallowedTool(newDisallowedTool);
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleAddDisallowedTool(newDisallowedTool);
}
}}
className="flex-1 h-10"
/>
addDisallowedTool(newDisallowedTool)}
- disabled={!newDisallowedTool}
+ onClick={() => handleAddDisallowedTool(newDisallowedTool)}
+ disabled={!newDisallowedTool.trim()}
size="sm"
className="h-10 px-4"
>
@@ -221,15 +224,13 @@ function ClaudePermissions({
- {disallowedTools.map(tool => (
+ {disallowedTools.map((tool) => (
-
- {tool}
-
+ {tool}
removeDisallowedTool(tool)}
+ onClick={() => onDisallowedToolsChange(removeValue(disallowedTools, tool))}
className="text-red-600 hover:text-red-700"
>
@@ -244,7 +245,6 @@ function ClaudePermissions({
- {/* Help Section */}
{t('permissions.toolExamples.title')}
@@ -260,58 +260,61 @@ function ClaudePermissions({
);
}
-// Cursor Permissions
+type CursorPermissionsProps = {
+ agent: 'cursor';
+ skipPermissions: boolean;
+ onSkipPermissionsChange: (value: boolean) => void;
+ allowedCommands: string[];
+ onAllowedCommandsChange: (value: string[]) => void;
+ disallowedCommands: string[];
+ onDisallowedCommandsChange: (value: string[]) => void;
+};
+
function CursorPermissions({
skipPermissions,
- setSkipPermissions,
+ onSkipPermissionsChange,
allowedCommands,
- setAllowedCommands,
+ onAllowedCommandsChange,
disallowedCommands,
- setDisallowedCommands,
- newAllowedCommand,
- setNewAllowedCommand,
- newDisallowedCommand,
- setNewDisallowedCommand,
-}) {
+ onDisallowedCommandsChange,
+}: Omit) {
const { t } = useTranslation('settings');
- const addAllowedCommand = (cmd) => {
- if (cmd && !allowedCommands.includes(cmd)) {
- setAllowedCommands([...allowedCommands, cmd]);
- setNewAllowedCommand('');
+ const [newAllowedCommand, setNewAllowedCommand] = useState('');
+ const [newDisallowedCommand, setNewDisallowedCommand] = useState('');
+
+ const handleAddAllowedCommand = (command: string) => {
+ const updated = addUnique(allowedCommands, command);
+ if (updated.length === allowedCommands.length) {
+ return;
}
+
+ onAllowedCommandsChange(updated);
+ setNewAllowedCommand('');
};
- const removeAllowedCommand = (cmd) => {
- setAllowedCommands(allowedCommands.filter(c => c !== cmd));
- };
-
- const addDisallowedCommand = (cmd) => {
- if (cmd && !disallowedCommands.includes(cmd)) {
- setDisallowedCommands([...disallowedCommands, cmd]);
- setNewDisallowedCommand('');
+ const handleAddDisallowedCommand = (command: string) => {
+ const updated = addUnique(disallowedCommands, command);
+ if (updated.length === disallowedCommands.length) {
+ return;
}
- };
- const removeDisallowedCommand = (cmd) => {
- setDisallowedCommands(disallowedCommands.filter(c => c !== cmd));
+ onDisallowedCommandsChange(updated);
+ setNewDisallowedCommand('');
};
return (
- {/* Skip Permissions */}
-
- {t('permissions.title')}
-
+
{t('permissions.title')}
- {/* Allowed Commands */}
-
- {t('permissions.allowedCommands.title')}
-
+ {t('permissions.allowedCommands.title')}
-
- {t('permissions.allowedCommands.description')}
-
+
{t('permissions.allowedCommands.description')}
setNewAllowedCommand(e.target.value)}
+ onChange={(event) => setNewAllowedCommand(event.target.value)}
placeholder={t('permissions.allowedCommands.placeholder')}
- onKeyPress={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addAllowedCommand(newAllowedCommand);
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleAddAllowedCommand(newAllowedCommand);
}
}}
className="flex-1 h-10"
/>
addAllowedCommand(newAllowedCommand)}
- disabled={!newAllowedCommand}
+ onClick={() => handleAddAllowedCommand(newAllowedCommand)}
+ disabled={!newAllowedCommand.trim()}
size="sm"
className="h-10 px-4"
>
@@ -362,37 +360,34 @@ function CursorPermissions({
- {/* Quick add buttons */}
{t('permissions.allowedCommands.quickAdd')}
- {commonCursorCommands.map(cmd => (
+ {COMMON_CURSOR_COMMANDS.map((command) => (
addAllowedCommand(cmd)}
- disabled={allowedCommands.includes(cmd)}
+ onClick={() => handleAddAllowedCommand(command)}
+ disabled={allowedCommands.includes(command)}
className="text-xs h-8"
>
- {cmd}
+ {command}
))}
- {allowedCommands.map(cmd => (
-
-
- {cmd}
-
+ {allowedCommands.map((command) => (
+
+ {command}
removeAllowedCommand(cmd)}
+ onClick={() => onAllowedCommandsChange(removeValue(allowedCommands, command))}
className="text-green-600 hover:text-green-700"
>
@@ -407,34 +402,29 @@ function CursorPermissions({
- {/* Disallowed Commands */}
-
- {t('permissions.blockedCommands.title')}
-
+
{t('permissions.blockedCommands.title')}
-
- {t('permissions.blockedCommands.description')}
-
+
{t('permissions.blockedCommands.description')}
setNewDisallowedCommand(e.target.value)}
+ onChange={(event) => setNewDisallowedCommand(event.target.value)}
placeholder={t('permissions.blockedCommands.placeholder')}
- onKeyPress={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- addDisallowedCommand(newDisallowedCommand);
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleAddDisallowedCommand(newDisallowedCommand);
}
}}
className="flex-1 h-10"
/>
addDisallowedCommand(newDisallowedCommand)}
- disabled={!newDisallowedCommand}
+ onClick={() => handleAddDisallowedCommand(newDisallowedCommand)}
+ disabled={!newDisallowedCommand.trim()}
size="sm"
className="h-10 px-4"
>
@@ -444,15 +434,13 @@ function CursorPermissions({
- {disallowedCommands.map(cmd => (
-
-
- {cmd}
-
+ {disallowedCommands.map((command) => (
+
+ {command}
removeDisallowedCommand(cmd)}
+ onClick={() => onDisallowedCommandsChange(removeValue(disallowedCommands, command))}
className="text-red-600 hover:text-red-700"
>
@@ -467,7 +455,6 @@ function CursorPermissions({
- {/* Help Section */}
{t('permissions.shellExamples.title')}
@@ -483,37 +470,38 @@ function CursorPermissions({
);
}
-// Codex Permissions
-function CodexPermissions({ permissionMode, setPermissionMode }) {
+type CodexPermissionsProps = {
+ agent: 'codex';
+ permissionMode: CodexPermissionMode;
+ onPermissionModeChange: (value: CodexPermissionMode) => void;
+};
+
+function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit) {
const { t } = useTranslation('settings');
+
return (
-
- {t('permissions.codex.permissionMode')}
-
+ {t('permissions.codex.permissionMode')}
-
- {t('permissions.codex.description')}
-
+
{t('permissions.codex.description')}
- {/* Default Mode */}
setPermissionMode('default')}
+ onClick={() => onPermissionModeChange('default')}
>
setPermissionMode('default')}
+ onChange={() => onPermissionModeChange('default')}
className="mt-1 w-4 h-4 text-green-600"
/>
@@ -525,21 +513,20 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
- {/* Accept Edits Mode */}
setPermissionMode('acceptEdits')}
+ onClick={() => onPermissionModeChange('acceptEdits')}
>
setPermissionMode('acceptEdits')}
+ onChange={() => onPermissionModeChange('acceptEdits')}
className="mt-1 w-4 h-4 text-green-600"
/>
@@ -551,21 +538,20 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
- {/* Bypass Permissions Mode */}
setPermissionMode('bypassPermissions')}
+ onClick={() => onPermissionModeChange('bypassPermissions')}
>
setPermissionMode('bypassPermissions')}
+ onChange={() => onPermissionModeChange('bypassPermissions')}
className="mt-1 w-4 h-4 text-orange-600"
/>
@@ -580,7 +566,6 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
- {/* Technical Details */}
{t('permissions.codex.technicalDetails')}
@@ -597,16 +582,16 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
);
}
-// Main component
-export default function PermissionsContent({ agent, ...props }) {
- if (agent === 'claude') {
+type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps;
+
+export default function PermissionsContent(props: PermissionsContentProps) {
+ if (props.agent === 'claude') {
return ;
}
- if (agent === 'cursor') {
+
+ if (props.agent === 'cursor') {
return ;
}
- if (agent === 'codex') {
- return ;
- }
- return null;
+
+ return ;
}
diff --git a/src/components/settings/view/tabs/agents-settings/types.ts b/src/components/settings/view/tabs/agents-settings/types.ts
new file mode 100644
index 0000000..80c178b
--- /dev/null
+++ b/src/components/settings/view/tabs/agents-settings/types.ts
@@ -0,0 +1,82 @@
+import type {
+ AgentProvider,
+ AuthStatus,
+ AgentCategory,
+ ClaudePermissionsState,
+ CodexPermissionMode,
+ CursorPermissionsState,
+ McpServer,
+ McpToolsResult,
+ McpTestResult,
+} from '../../../types/types';
+
+export type AgentContext = {
+ authStatus: AuthStatus;
+ onLogin: () => void;
+};
+
+export type AgentContextByProvider = Record;
+
+export type AgentsSettingsTabProps = {
+ claudeAuthStatus: AuthStatus;
+ cursorAuthStatus: AuthStatus;
+ codexAuthStatus: AuthStatus;
+ onClaudeLogin: () => void;
+ onCursorLogin: () => void;
+ onCodexLogin: () => void;
+ claudePermissions: ClaudePermissionsState;
+ onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
+ cursorPermissions: CursorPermissionsState;
+ onCursorPermissionsChange: (value: CursorPermissionsState) => void;
+ codexPermissionMode: CodexPermissionMode;
+ onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
+ mcpServers: McpServer[];
+ cursorMcpServers: McpServer[];
+ codexMcpServers: McpServer[];
+ mcpTestResults: Record;
+ mcpServerTools: Record;
+ mcpToolsLoading: Record;
+ deleteError: string | null;
+ onOpenMcpForm: (server?: McpServer) => void;
+ onDeleteMcpServer: (serverId: string, scope?: string) => void;
+ onTestMcpServer: (serverId: string, scope?: string) => void;
+ onDiscoverMcpTools: (serverId: string, scope?: string) => void;
+ onOpenCodexMcpForm: (server?: McpServer) => void;
+ onDeleteCodexMcpServer: (serverId: string) => void;
+};
+
+export type AgentCategoryTabsSectionProps = {
+ selectedCategory: AgentCategory;
+ onSelectCategory: (category: AgentCategory) => void;
+};
+
+export type AgentSelectorSectionProps = {
+ selectedAgent: AgentProvider;
+ onSelectAgent: (agent: AgentProvider) => void;
+ agentContextById: AgentContextByProvider;
+};
+
+export type AgentCategoryContentSectionProps = {
+ selectedAgent: AgentProvider;
+ selectedCategory: AgentCategory;
+ agentContextById: AgentContextByProvider;
+ claudePermissions: ClaudePermissionsState;
+ onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
+ cursorPermissions: CursorPermissionsState;
+ onCursorPermissionsChange: (value: CursorPermissionsState) => void;
+ codexPermissionMode: CodexPermissionMode;
+ onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
+ mcpServers: McpServer[];
+ cursorMcpServers: McpServer[];
+ codexMcpServers: McpServer[];
+ mcpTestResults: Record;
+ mcpServerTools: Record;
+ mcpToolsLoading: Record;
+ deleteError: string | null;
+ onOpenMcpForm: (server?: McpServer) => void;
+ onDeleteMcpServer: (serverId: string, scope?: string) => void;
+ onTestMcpServer: (serverId: string, scope?: string) => void;
+ onDiscoverMcpTools: (serverId: string, scope?: string) => void;
+ onOpenCodexMcpForm: (server?: McpServer) => void;
+ onDeleteCodexMcpServer: (serverId: string) => void;
+};
diff --git a/src/components/settings/view/tabs/api-settings/CredentialsSettingsTab.tsx b/src/components/settings/view/tabs/api-settings/CredentialsSettingsTab.tsx
new file mode 100644
index 0000000..0f47512
--- /dev/null
+++ b/src/components/settings/view/tabs/api-settings/CredentialsSettingsTab.tsx
@@ -0,0 +1,100 @@
+import { useTranslation } from 'react-i18next';
+import { useVersionCheck } from '../../../../../hooks/useVersionCheck';
+import { useCredentialsSettings } from '../../../hooks/useCredentialsSettings';
+import ApiKeysSection from './sections/ApiKeysSection';
+import GithubCredentialsSection from './sections/GithubCredentialsSection';
+import NewApiKeyAlert from './sections/NewApiKeyAlert';
+import VersionInfoSection from './sections/VersionInfoSection';
+
+export default function CredentialsSettingsTab() {
+ const { t } = useTranslation('settings');
+ const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
+ const {
+ apiKeys,
+ githubCredentials,
+ loading,
+ showNewKeyForm,
+ setShowNewKeyForm,
+ newKeyName,
+ setNewKeyName,
+ showNewGithubForm,
+ setShowNewGithubForm,
+ newGithubName,
+ setNewGithubName,
+ newGithubToken,
+ setNewGithubToken,
+ newGithubDescription,
+ setNewGithubDescription,
+ showToken,
+ copiedKey,
+ newlyCreatedKey,
+ createApiKey,
+ deleteApiKey,
+ toggleApiKey,
+ createGithubCredential,
+ deleteGithubCredential,
+ toggleGithubCredential,
+ copyToClipboard,
+ dismissNewlyCreatedKey,
+ cancelNewApiKeyForm,
+ cancelNewGithubForm,
+ toggleNewGithubTokenVisibility,
+ } = useCredentialsSettings({
+ confirmDeleteApiKeyText: t('apiKeys.confirmDelete'),
+ confirmDeleteGithubCredentialText: t('apiKeys.github.confirmDelete'),
+ });
+
+ if (loading) {
+ return {t('apiKeys.loading')}
;
+ }
+
+ return (
+
+ {newlyCreatedKey && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/view/tabs/api-settings/sections/ApiKeysSection.tsx b/src/components/settings/view/tabs/api-settings/sections/ApiKeysSection.tsx
new file mode 100644
index 0000000..200d9c9
--- /dev/null
+++ b/src/components/settings/view/tabs/api-settings/sections/ApiKeysSection.tsx
@@ -0,0 +1,109 @@
+import { ExternalLink, Key, Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '../../../../../ui/button';
+import { Input } from '../../../../../ui/input';
+import type { ApiKeyItem } from '../types';
+
+type ApiKeysSectionProps = {
+ apiKeys: ApiKeyItem[];
+ showNewKeyForm: boolean;
+ newKeyName: string;
+ onShowNewKeyFormChange: (value: boolean) => void;
+ onNewKeyNameChange: (value: string) => void;
+ onCreateApiKey: () => void;
+ onCancelCreateApiKey: () => void;
+ onToggleApiKey: (keyId: string, isActive: boolean) => void;
+ onDeleteApiKey: (keyId: string) => void;
+};
+
+export default function ApiKeysSection({
+ apiKeys,
+ showNewKeyForm,
+ newKeyName,
+ onShowNewKeyFormChange,
+ onNewKeyNameChange,
+ onCreateApiKey,
+ onCancelCreateApiKey,
+ onToggleApiKey,
+ onDeleteApiKey,
+}: ApiKeysSectionProps) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
+
+
+
{t('apiKeys.title')}
+
+
onShowNewKeyFormChange(!showNewKeyForm)}>
+
+ {t('apiKeys.newButton')}
+
+
+
+
+
+ {showNewKeyForm && (
+
+
onNewKeyNameChange(event.target.value)}
+ className="mb-2"
+ />
+
+ {t('apiKeys.form.createButton')}
+
+ {t('apiKeys.form.cancelButton')}
+
+
+
+ )}
+
+
+ {apiKeys.length === 0 ? (
+
{t('apiKeys.empty')}
+ ) : (
+ apiKeys.map((key) => (
+
+
+
{key.key_name}
+
{key.api_key}
+
+ {t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
+ {key.last_used
+ ? ` - ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`
+ : ''}
+
+
+
+ onToggleApiKey(key.id, key.is_active)}
+ >
+ {key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
+
+ onDeleteApiKey(key.id)}>
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/components/settings/view/tabs/api-settings/sections/GithubCredentialsSection.tsx b/src/components/settings/view/tabs/api-settings/sections/GithubCredentialsSection.tsx
new file mode 100644
index 0000000..3e9f5d7
--- /dev/null
+++ b/src/components/settings/view/tabs/api-settings/sections/GithubCredentialsSection.tsx
@@ -0,0 +1,142 @@
+import { Eye, EyeOff, Github, Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '../../../../../ui/button';
+import { Input } from '../../../../../ui/input';
+import type { GithubCredentialItem } from '../types';
+
+type GithubCredentialsSectionProps = {
+ githubCredentials: GithubCredentialItem[];
+ showNewGithubForm: boolean;
+ showNewTokenPlainText: boolean;
+ newGithubName: string;
+ newGithubToken: string;
+ newGithubDescription: string;
+ onShowNewGithubFormChange: (value: boolean) => void;
+ onNewGithubNameChange: (value: string) => void;
+ onNewGithubTokenChange: (value: string) => void;
+ onNewGithubDescriptionChange: (value: string) => void;
+ onToggleNewTokenVisibility: () => void;
+ onCreateGithubCredential: () => void;
+ onCancelCreateGithubCredential: () => void;
+ onToggleGithubCredential: (credentialId: string, isActive: boolean) => void;
+ onDeleteGithubCredential: (credentialId: string) => void;
+};
+
+export default function GithubCredentialsSection({
+ githubCredentials,
+ showNewGithubForm,
+ showNewTokenPlainText,
+ newGithubName,
+ newGithubToken,
+ newGithubDescription,
+ onShowNewGithubFormChange,
+ onNewGithubNameChange,
+ onNewGithubTokenChange,
+ onNewGithubDescriptionChange,
+ onToggleNewTokenVisibility,
+ onCreateGithubCredential,
+ onCancelCreateGithubCredential,
+ onToggleGithubCredential,
+ onDeleteGithubCredential,
+}: GithubCredentialsSectionProps) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
+
+
+
{t('apiKeys.github.title')}
+
+
onShowNewGithubFormChange(!showNewGithubForm)}>
+
+ {t('apiKeys.github.addButton')}
+
+
+
+
{t('apiKeys.github.descriptionAlt')}
+
+ {showNewGithubForm && (
+
+ )}
+
+
+ {githubCredentials.length === 0 ? (
+
{t('apiKeys.github.empty')}
+ ) : (
+ githubCredentials.map((credential) => (
+
+
+
{credential.credential_name}
+ {credential.description && (
+
{credential.description}
+ )}
+
+ {t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
+
+
+
+ onToggleGithubCredential(credential.id, credential.is_active)}
+ >
+ {credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
+
+ onDeleteGithubCredential(credential.id)}>
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/components/settings/view/tabs/api-settings/sections/NewApiKeyAlert.tsx b/src/components/settings/view/tabs/api-settings/sections/NewApiKeyAlert.tsx
new file mode 100644
index 0000000..9d78746
--- /dev/null
+++ b/src/components/settings/view/tabs/api-settings/sections/NewApiKeyAlert.tsx
@@ -0,0 +1,42 @@
+import { Check, Copy } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '../../../../../ui/button';
+import type { CreatedApiKey } from '../types';
+
+type NewApiKeyAlertProps = {
+ apiKey: CreatedApiKey;
+ copiedKey: string | null;
+ onCopy: (text: string, id: string) => void;
+ onDismiss: () => void;
+};
+
+export default function NewApiKeyAlert({
+ apiKey,
+ copiedKey,
+ onCopy,
+ onDismiss,
+}: NewApiKeyAlertProps) {
+ const { t } = useTranslation('settings');
+
+ return (
+
+
{t('apiKeys.newKey.alertTitle')}
+
{t('apiKeys.newKey.alertMessage')}
+
+
+ {apiKey.apiKey}
+
+ onCopy(apiKey.apiKey, 'new')}
+ >
+ {copiedKey === 'new' ? : }
+
+
+
+ {t('apiKeys.newKey.iveSavedIt')}
+
+
+ );
+}
diff --git a/src/components/settings/view/tabs/api-settings/sections/VersionInfoSection.tsx b/src/components/settings/view/tabs/api-settings/sections/VersionInfoSection.tsx
new file mode 100644
index 0000000..47ff62b
--- /dev/null
+++ b/src/components/settings/view/tabs/api-settings/sections/VersionInfoSection.tsx
@@ -0,0 +1,46 @@
+import { ExternalLink } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
+
+type VersionInfoSectionProps = {
+ currentVersion: string;
+ updateAvailable: boolean;
+ latestVersion: string | null;
+ releaseInfo: ReleaseInfo | null;
+};
+
+export default function VersionInfoSection({
+ currentVersion,
+ updateAvailable,
+ latestVersion,
+ releaseInfo,
+}: VersionInfoSectionProps) {
+ const { t } = useTranslation('settings');
+ const releasesUrl = releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases';
+
+ return (
+
+ );
+}
diff --git a/src/components/settings/view/tabs/api-settings/types.ts b/src/components/settings/view/tabs/api-settings/types.ts
new file mode 100644
index 0000000..b3d3ae1
--- /dev/null
+++ b/src/components/settings/view/tabs/api-settings/types.ts
@@ -0,0 +1,36 @@
+export type ApiKeyItem = {
+ id: string;
+ key_name: string;
+ api_key: string;
+ created_at: string;
+ last_used?: string | null;
+ is_active: boolean;
+};
+
+export type CreatedApiKey = {
+ id: string;
+ keyName: string;
+ apiKey: string;
+ createdAt?: string;
+};
+
+export type GithubCredentialItem = {
+ id: string;
+ credential_name: string;
+ description?: string | null;
+ created_at: string;
+ is_active: boolean;
+};
+
+export type ApiKeysResponse = {
+ apiKeys?: ApiKeyItem[];
+ success?: boolean;
+ error?: string;
+ apiKey?: CreatedApiKey;
+};
+
+export type GithubCredentialsResponse = {
+ credentials?: GithubCredentialItem[];
+ success?: boolean;
+ error?: string;
+};
diff --git a/src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx b/src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx
new file mode 100644
index 0000000..84f633a
--- /dev/null
+++ b/src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx
@@ -0,0 +1,82 @@
+import { Check, GitBranch } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { useGitSettings } from '../../../hooks/useGitSettings';
+import { Button } from '../../../../ui/button';
+import { Input } from '../../../../ui/input';
+
+export default function GitSettingsTab() {
+ const { t } = useTranslation('settings');
+ const {
+ gitName,
+ setGitName,
+ gitEmail,
+ setGitEmail,
+ isLoading,
+ isSaving,
+ saveStatus,
+ saveGitConfig,
+ } = useGitSettings();
+
+ return (
+
+
+
+
+
{t('git.title')}
+
+
+
{t('git.description')}
+
+
+
+
+ {t('git.name.label')}
+
+
setGitName(event.target.value)}
+ placeholder="John Doe"
+ disabled={isLoading}
+ className="w-full"
+ />
+
{t('git.name.help')}
+
+
+
+
+ {t('git.email.label')}
+
+
setGitEmail(event.target.value)}
+ placeholder="john@example.com"
+ disabled={isLoading}
+ className="w-full"
+ />
+
{t('git.email.help')}
+
+
+
+
+ {isSaving ? t('git.actions.saving') : t('git.actions.save')}
+
+
+ {saveStatus === 'success' && (
+
+
+ {t('git.status.success')}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx b/src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx
new file mode 100644
index 0000000..55423e1
--- /dev/null
+++ b/src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx
@@ -0,0 +1,106 @@
+import { useTranslation } from 'react-i18next';
+import { useTasksSettings } from '../../../../../contexts/TasksSettingsContext';
+
+type TasksSettingsContextValue = {
+ tasksEnabled: boolean;
+ setTasksEnabled: (enabled: boolean) => void;
+ isTaskMasterInstalled: boolean | null;
+ isCheckingInstallation: boolean;
+};
+
+export default function TasksSettingsTab() {
+ const { t } = useTranslation('settings');
+ const {
+ tasksEnabled,
+ setTasksEnabled,
+ isTaskMasterInstalled,
+ isCheckingInstallation,
+ } = useTasksSettings() as TasksSettingsContextValue;
+
+ return (
+
+ {isCheckingInstallation ? (
+
+
+
+
{t('tasks.checking')}
+
+
+ ) : (
+ <>
+ {!isTaskMasterInstalled && (
+
+
+
+
+
+ {t('tasks.notInstalled.title')}
+
+
+
{t('tasks.notInstalled.description')}
+
+
+ {t('tasks.notInstalled.installCommand')}
+
+
+
+
+
+
{t('tasks.notInstalled.afterInstallation')}
+
+ {t('tasks.notInstalled.steps.restart')}
+ {t('tasks.notInstalled.steps.autoAvailable')}
+ {t('tasks.notInstalled.steps.initCommand')}
+
+
+
+
+
+
+ )}
+
+ {isTaskMasterInstalled && (
+
+
+
+
+
{t('tasks.settings.enableLabel')}
+
{t('tasks.settings.enableDescription')}
+
+
+ setTasksEnabled(event.target.checked)}
+ className="sr-only peer"
+ />
+
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/shell/constants/constants.ts b/src/components/shell/constants/constants.ts
new file mode 100644
index 0000000..9a523d4
--- /dev/null
+++ b/src/components/shell/constants/constants.ts
@@ -0,0 +1,63 @@
+import type { ITerminalOptions } from '@xterm/xterm';
+
+export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
+export const SHELL_RESTART_DELAY_MS = 200;
+export const TERMINAL_INIT_DELAY_MS = 100;
+export const TERMINAL_RESIZE_DELAY_MS = 50;
+
+export const TERMINAL_OPTIONS: ITerminalOptions = {
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
+ allowProposedApi: true,
+ allowTransparency: false,
+ convertEol: true,
+ scrollback: 10000,
+ tabStopWidth: 4,
+ windowsMode: false,
+ macOptionIsMeta: true,
+ macOptionClickForcesSelection: true,
+ // Keep the runtime theme keys used by the previous JSX implementation.
+ theme: {
+ background: '#1e1e1e',
+ foreground: '#d4d4d4',
+ cursor: '#ffffff',
+ cursorAccent: '#1e1e1e',
+ selectionBackground: '#264f78',
+ selectionForeground: '#ffffff',
+ black: '#000000',
+ red: '#cd3131',
+ green: '#0dbc79',
+ yellow: '#e5e510',
+ blue: '#2472c8',
+ magenta: '#bc3fbc',
+ cyan: '#11a8cd',
+ white: '#e5e5e5',
+ brightBlack: '#666666',
+ brightRed: '#f14c4c',
+ brightGreen: '#23d18b',
+ brightYellow: '#f5f543',
+ brightBlue: '#3b8eea',
+ brightMagenta: '#d670d6',
+ brightCyan: '#29b8db',
+ brightWhite: '#ffffff',
+ extendedAnsi: [
+ '#000000',
+ '#800000',
+ '#008000',
+ '#808000',
+ '#000080',
+ '#800080',
+ '#008080',
+ '#c0c0c0',
+ '#808080',
+ '#ff0000',
+ '#00ff00',
+ '#ffff00',
+ '#0000ff',
+ '#ff00ff',
+ '#00ffff',
+ '#ffffff',
+ ],
+ },
+};
diff --git a/src/components/shell/hooks/useShellConnection.ts b/src/components/shell/hooks/useShellConnection.ts
new file mode 100644
index 0000000..b60ef94
--- /dev/null
+++ b/src/components/shell/hooks/useShellConnection.ts
@@ -0,0 +1,229 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { MutableRefObject } from 'react';
+import type { FitAddon } from '@xterm/addon-fit';
+import type { Terminal } from '@xterm/xterm';
+import type { Project, ProjectSession } from '../../../types/app';
+import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
+import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
+
+const ANSI_ESCAPE_REGEX =
+ /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u009B[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\u009D[^\u0007\u009C]*(?:\u0007|\u009C)|\u001B[PX^_][^\u001B]*\u001B\\|[\u0090\u0098\u009E\u009F][^\u009C]*\u009C|\u001B[@-Z\\-_])/g;
+const PROCESS_EXIT_REGEX = /Process exited with code (\d+)/;
+
+type UseShellConnectionOptions = {
+ wsRef: MutableRefObject;
+ terminalRef: MutableRefObject;
+ fitAddonRef: MutableRefObject;
+ selectedProjectRef: MutableRefObject;
+ selectedSessionRef: MutableRefObject;
+ initialCommandRef: MutableRefObject;
+ isPlainShellRef: MutableRefObject;
+ onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;
+ isInitialized: boolean;
+ autoConnect: boolean;
+ closeSocket: () => void;
+ clearTerminalScreen: () => void;
+ setAuthUrl: (nextAuthUrl: string) => void;
+};
+
+type UseShellConnectionResult = {
+ isConnected: boolean;
+ isConnecting: boolean;
+ closeSocket: () => void;
+ connectToShell: () => void;
+ disconnectFromShell: () => void;
+};
+
+export function useShellConnection({
+ wsRef,
+ terminalRef,
+ fitAddonRef,
+ selectedProjectRef,
+ selectedSessionRef,
+ initialCommandRef,
+ isPlainShellRef,
+ onProcessCompleteRef,
+ isInitialized,
+ autoConnect,
+ closeSocket,
+ clearTerminalScreen,
+ setAuthUrl,
+}: UseShellConnectionOptions): UseShellConnectionResult {
+ const [isConnected, setIsConnected] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const connectingRef = useRef(false);
+
+ const handleProcessCompletion = useCallback(
+ (output: string) => {
+ if (!isPlainShellRef.current || !onProcessCompleteRef.current) {
+ return;
+ }
+
+ const sanitizedOutput = output.replace(ANSI_ESCAPE_REGEX, '');
+ const cleanOutput = sanitizedOutput;
+ if (cleanOutput.includes('Process exited with code 0')) {
+ onProcessCompleteRef.current(0);
+ return;
+ }
+
+ const match = cleanOutput.match(PROCESS_EXIT_REGEX);
+ if (!match) {
+ return;
+ }
+
+ const exitCode = Number.parseInt(match[1], 10);
+ if (!Number.isNaN(exitCode) && exitCode !== 0) {
+ onProcessCompleteRef.current(exitCode);
+ }
+ },
+ [isPlainShellRef, onProcessCompleteRef],
+ );
+
+ const handleSocketMessage = useCallback(
+ (rawPayload: string) => {
+ const message = parseShellMessage(rawPayload);
+ if (!message) {
+ console.error('[Shell] Error handling WebSocket message:', rawPayload);
+ return;
+ }
+
+ if (message.type === 'output') {
+ const output = typeof message.data === 'string' ? message.data : '';
+ handleProcessCompletion(output);
+ terminalRef.current?.write(output);
+ return;
+ }
+
+ if (message.type === 'auth_url' || message.type === 'url_open') {
+ const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
+ if (nextAuthUrl) {
+ setAuthUrl(nextAuthUrl);
+ }
+ }
+ },
+ [handleProcessCompletion, setAuthUrl, terminalRef],
+ );
+
+ const connectWebSocket = useCallback(
+ (isConnectionLocked = false) => {
+ if ((connectingRef.current && !isConnectionLocked) || isConnecting || isConnected) {
+ return;
+ }
+
+ try {
+ const wsUrl = getShellWebSocketUrl();
+ if (!wsUrl) {
+ connectingRef.current = false;
+ setIsConnecting(false);
+ return;
+ }
+
+ connectingRef.current = true;
+
+ const socket = new WebSocket(wsUrl);
+ wsRef.current = socket;
+
+ socket.onopen = () => {
+ setIsConnected(true);
+ setIsConnecting(false);
+ connectingRef.current = false;
+ setAuthUrl('');
+
+ window.setTimeout(() => {
+ const currentTerminal = terminalRef.current;
+ const currentFitAddon = fitAddonRef.current;
+ const currentProject = selectedProjectRef.current;
+ if (!currentTerminal || !currentFitAddon || !currentProject) {
+ return;
+ }
+
+ currentFitAddon.fit();
+
+ sendSocketMessage(socket, {
+ type: 'init',
+ projectPath: currentProject.fullPath || currentProject.path || '',
+ sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null,
+ hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current),
+ provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-provider') || 'claude'),
+ cols: currentTerminal.cols,
+ rows: currentTerminal.rows,
+ initialCommand: initialCommandRef.current,
+ isPlainShell: isPlainShellRef.current,
+ });
+ }, TERMINAL_INIT_DELAY_MS);
+ };
+
+ socket.onmessage = (event) => {
+ const rawPayload = typeof event.data === 'string' ? event.data : String(event.data ?? '');
+ handleSocketMessage(rawPayload);
+ };
+
+ socket.onclose = () => {
+ setIsConnected(false);
+ setIsConnecting(false);
+ connectingRef.current = false;
+ clearTerminalScreen();
+ };
+
+ socket.onerror = () => {
+ setIsConnected(false);
+ setIsConnecting(false);
+ connectingRef.current = false;
+ };
+ } catch {
+ setIsConnected(false);
+ setIsConnecting(false);
+ connectingRef.current = false;
+ }
+ },
+ [
+ clearTerminalScreen,
+ fitAddonRef,
+ handleSocketMessage,
+ initialCommandRef,
+ isConnected,
+ isConnecting,
+ isPlainShellRef,
+ selectedProjectRef,
+ selectedSessionRef,
+ setAuthUrl,
+ terminalRef,
+ wsRef,
+ ],
+ );
+
+ const connectToShell = useCallback(() => {
+ if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
+ return;
+ }
+
+ connectingRef.current = true;
+ setIsConnecting(true);
+ connectWebSocket(true);
+ }, [connectWebSocket, isConnected, isConnecting, isInitialized]);
+
+ const disconnectFromShell = useCallback(() => {
+ closeSocket();
+ clearTerminalScreen();
+ setIsConnected(false);
+ setIsConnecting(false);
+ connectingRef.current = false;
+ setAuthUrl('');
+ }, [clearTerminalScreen, closeSocket, setAuthUrl]);
+
+ useEffect(() => {
+ if (!autoConnect || !isInitialized || isConnecting || isConnected) {
+ return;
+ }
+
+ connectToShell();
+ }, [autoConnect, connectToShell, isConnected, isConnecting, isInitialized]);
+
+ return {
+ isConnected,
+ isConnecting,
+ closeSocket,
+ connectToShell,
+ disconnectFromShell,
+ };
+}
diff --git a/src/components/shell/hooks/useShellRuntime.ts b/src/components/shell/hooks/useShellRuntime.ts
new file mode 100644
index 0000000..0af6d2f
--- /dev/null
+++ b/src/components/shell/hooks/useShellRuntime.ts
@@ -0,0 +1,162 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { FitAddon } from '@xterm/addon-fit';
+import type { Terminal } from '@xterm/xterm';
+import { useShellConnection } from './useShellConnection';
+import { useShellTerminal } from './useShellTerminal';
+import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
+import { copyTextToClipboard } from '../../../utils/clipboard';
+
+export function useShellRuntime({
+ selectedProject,
+ selectedSession,
+ initialCommand,
+ isPlainShell,
+ minimal,
+ autoConnect,
+ isRestarting,
+ onProcessComplete,
+}: UseShellRuntimeOptions): UseShellRuntimeResult {
+ const terminalContainerRef = useRef(null);
+ const terminalRef = useRef(null);
+ const fitAddonRef = useRef(null);
+ const wsRef = useRef(null);
+
+ const [authUrl, setAuthUrl] = useState('');
+ const [authUrlVersion, setAuthUrlVersion] = useState(0);
+
+ const selectedProjectRef = useRef(selectedProject);
+ const selectedSessionRef = useRef(selectedSession);
+ const initialCommandRef = useRef(initialCommand);
+ const isPlainShellRef = useRef(isPlainShell);
+ const onProcessCompleteRef = useRef(onProcessComplete);
+ const authUrlRef = useRef('');
+ const lastSessionIdRef = useRef(selectedSession?.id ?? null);
+
+ // Keep mutable values in refs so websocket handlers always read current data.
+ useEffect(() => {
+ selectedProjectRef.current = selectedProject;
+ selectedSessionRef.current = selectedSession;
+ initialCommandRef.current = initialCommand;
+ isPlainShellRef.current = isPlainShell;
+ onProcessCompleteRef.current = onProcessComplete;
+ }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
+
+ const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
+ authUrlRef.current = nextAuthUrl;
+ setAuthUrl(nextAuthUrl);
+ setAuthUrlVersion((previous) => previous + 1);
+ }, []);
+
+ const closeSocket = useCallback(() => {
+ const activeSocket = wsRef.current;
+ if (!activeSocket) {
+ return;
+ }
+
+ if (
+ activeSocket.readyState === WebSocket.OPEN ||
+ activeSocket.readyState === WebSocket.CONNECTING
+ ) {
+ activeSocket.close();
+ }
+
+ wsRef.current = null;
+ }, []);
+
+ const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
+ if (!url) {
+ return false;
+ }
+
+ const popup = window.open(url, '_blank');
+ if (popup) {
+ try {
+ popup.opener = null;
+ } catch {
+ // Ignore cross-origin restrictions when trying to null opener.
+ }
+ return true;
+ }
+
+ return false;
+ }, []);
+
+ const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
+ if (!url) {
+ return false;
+ }
+
+ return copyTextToClipboard(url);
+ }, []);
+
+ const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
+ terminalContainerRef,
+ terminalRef,
+ fitAddonRef,
+ wsRef,
+ selectedProject,
+ minimal,
+ isRestarting,
+ initialCommandRef,
+ isPlainShellRef,
+ authUrlRef,
+ copyAuthUrlToClipboard,
+ closeSocket,
+ });
+
+ const { isConnected, isConnecting, connectToShell, disconnectFromShell } = useShellConnection({
+ wsRef,
+ terminalRef,
+ fitAddonRef,
+ selectedProjectRef,
+ selectedSessionRef,
+ initialCommandRef,
+ isPlainShellRef,
+ onProcessCompleteRef,
+ isInitialized,
+ autoConnect,
+ closeSocket,
+ clearTerminalScreen,
+ setAuthUrl: setCurrentAuthUrl,
+ });
+
+ useEffect(() => {
+ if (!isRestarting) {
+ return;
+ }
+
+ disconnectFromShell();
+ disposeTerminal();
+ }, [disconnectFromShell, disposeTerminal, isRestarting]);
+
+ useEffect(() => {
+ if (selectedProject) {
+ return;
+ }
+
+ disconnectFromShell();
+ disposeTerminal();
+ }, [disconnectFromShell, disposeTerminal, selectedProject]);
+
+ useEffect(() => {
+ const currentSessionId = selectedSession?.id ?? null;
+ if (lastSessionIdRef.current !== currentSessionId && isInitialized) {
+ disconnectFromShell();
+ }
+
+ lastSessionIdRef.current = currentSessionId;
+ }, [disconnectFromShell, isInitialized, selectedSession?.id]);
+
+ return {
+ terminalContainerRef,
+ isConnected,
+ isInitialized,
+ isConnecting,
+ authUrl,
+ authUrlVersion,
+ connectToShell,
+ disconnectFromShell,
+ openAuthUrlInBrowser,
+ copyAuthUrlToClipboard,
+ };
+}
diff --git a/src/components/shell/hooks/useShellTerminal.ts b/src/components/shell/hooks/useShellTerminal.ts
new file mode 100644
index 0000000..cf7afc5
--- /dev/null
+++ b/src/components/shell/hooks/useShellTerminal.ts
@@ -0,0 +1,245 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { MutableRefObject, RefObject } from 'react';
+import { FitAddon } from '@xterm/addon-fit';
+import { WebLinksAddon } from '@xterm/addon-web-links';
+import { WebglAddon } from '@xterm/addon-webgl';
+import { Terminal } from '@xterm/xterm';
+import type { Project } from '../../../types/app';
+import {
+ CODEX_DEVICE_AUTH_URL,
+ TERMINAL_INIT_DELAY_MS,
+ TERMINAL_OPTIONS,
+ TERMINAL_RESIZE_DELAY_MS,
+} from '../constants/constants';
+import { isCodexLoginCommand } from '../utils/auth';
+import { sendSocketMessage } from '../utils/socket';
+import { ensureXtermFocusStyles } from '../utils/terminalStyles';
+
+type UseShellTerminalOptions = {
+ terminalContainerRef: RefObject;
+ terminalRef: MutableRefObject;
+ fitAddonRef: MutableRefObject;
+ wsRef: MutableRefObject;
+ selectedProject: Project | null | undefined;
+ minimal: boolean;
+ isRestarting: boolean;
+ initialCommandRef: MutableRefObject;
+ isPlainShellRef: MutableRefObject;
+ authUrlRef: MutableRefObject;
+ copyAuthUrlToClipboard: (url?: string) => Promise;
+ closeSocket: () => void;
+};
+
+type UseShellTerminalResult = {
+ isInitialized: boolean;
+ clearTerminalScreen: () => void;
+ disposeTerminal: () => void;
+};
+
+export function useShellTerminal({
+ terminalContainerRef,
+ terminalRef,
+ fitAddonRef,
+ wsRef,
+ selectedProject,
+ minimal,
+ isRestarting,
+ initialCommandRef,
+ isPlainShellRef,
+ authUrlRef,
+ copyAuthUrlToClipboard,
+ closeSocket,
+}: UseShellTerminalOptions): UseShellTerminalResult {
+ const [isInitialized, setIsInitialized] = useState(false);
+ const resizeTimeoutRef = useRef(null);
+ const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
+ const hasSelectedProject = Boolean(selectedProject);
+
+ useEffect(() => {
+ ensureXtermFocusStyles();
+ }, []);
+
+ const clearTerminalScreen = useCallback(() => {
+ if (!terminalRef.current) {
+ return;
+ }
+
+ terminalRef.current.clear();
+ terminalRef.current.write('\x1b[2J\x1b[H');
+ }, [terminalRef]);
+
+ const disposeTerminal = useCallback(() => {
+ if (terminalRef.current) {
+ terminalRef.current.dispose();
+ terminalRef.current = null;
+ }
+
+ fitAddonRef.current = null;
+ setIsInitialized(false);
+ }, [fitAddonRef, terminalRef]);
+
+ useEffect(() => {
+ if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
+ return;
+ }
+
+ const nextTerminal = new Terminal(TERMINAL_OPTIONS);
+ terminalRef.current = nextTerminal;
+
+ const nextFitAddon = new FitAddon();
+ fitAddonRef.current = nextFitAddon;
+ nextTerminal.loadAddon(nextFitAddon);
+
+ // Avoid wrapped partial links in compact login flows.
+ if (!minimal) {
+ nextTerminal.loadAddon(new WebLinksAddon());
+ }
+
+ try {
+ nextTerminal.loadAddon(new WebglAddon());
+ } catch {
+ console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
+ }
+
+ nextTerminal.open(terminalContainerRef.current);
+
+ nextTerminal.attachCustomKeyEventHandler((event) => {
+ const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
+ ? CODEX_DEVICE_AUTH_URL
+ : authUrlRef.current;
+
+ if (
+ event.type === 'keydown' &&
+ minimal &&
+ isPlainShellRef.current &&
+ activeAuthUrl &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.altKey &&
+ event.key?.toLowerCase() === 'c'
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ void copyAuthUrlToClipboard(activeAuthUrl);
+ return false;
+ }
+
+ if (
+ event.type === 'keydown' &&
+ (event.ctrlKey || event.metaKey) &&
+ event.key?.toLowerCase() === 'c' &&
+ nextTerminal.hasSelection()
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ document.execCommand('copy');
+ return false;
+ }
+
+ if (
+ event.type === 'keydown' &&
+ (event.ctrlKey || event.metaKey) &&
+ event.key?.toLowerCase() === 'v'
+ ) {
+ // Block native paste so data is only injected after clipboard-read resolves.
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) {
+ navigator.clipboard
+ .readText()
+ .then((text) => {
+ sendSocketMessage(wsRef.current, {
+ type: 'input',
+ data: text,
+ });
+ })
+ .catch(() => {});
+ }
+
+ return false;
+ }
+
+ return true;
+ });
+
+ window.setTimeout(() => {
+ const currentFitAddon = fitAddonRef.current;
+ const currentTerminal = terminalRef.current;
+ if (!currentFitAddon || !currentTerminal) {
+ return;
+ }
+
+ currentFitAddon.fit();
+ sendSocketMessage(wsRef.current, {
+ type: 'resize',
+ cols: currentTerminal.cols,
+ rows: currentTerminal.rows,
+ });
+ }, TERMINAL_INIT_DELAY_MS);
+
+ setIsInitialized(true);
+
+ const dataSubscription = nextTerminal.onData((data) => {
+ sendSocketMessage(wsRef.current, {
+ type: 'input',
+ data,
+ });
+ });
+
+ const resizeObserver = new ResizeObserver(() => {
+ if (resizeTimeoutRef.current !== null) {
+ window.clearTimeout(resizeTimeoutRef.current);
+ }
+
+ resizeTimeoutRef.current = window.setTimeout(() => {
+ const currentFitAddon = fitAddonRef.current;
+ const currentTerminal = terminalRef.current;
+ if (!currentFitAddon || !currentTerminal) {
+ return;
+ }
+
+ currentFitAddon.fit();
+ sendSocketMessage(wsRef.current, {
+ type: 'resize',
+ cols: currentTerminal.cols,
+ rows: currentTerminal.rows,
+ });
+ }, TERMINAL_RESIZE_DELAY_MS);
+ });
+
+ resizeObserver.observe(terminalContainerRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ if (resizeTimeoutRef.current !== null) {
+ window.clearTimeout(resizeTimeoutRef.current);
+ resizeTimeoutRef.current = null;
+ }
+ dataSubscription.dispose();
+ closeSocket();
+ disposeTerminal();
+ };
+ }, [
+ authUrlRef,
+ closeSocket,
+ copyAuthUrlToClipboard,
+ disposeTerminal,
+ fitAddonRef,
+ initialCommandRef,
+ isPlainShellRef,
+ isRestarting,
+ minimal,
+ hasSelectedProject,
+ selectedProjectKey,
+ terminalContainerRef,
+ terminalRef,
+ wsRef,
+ ]);
+
+ return {
+ isInitialized,
+ clearTerminalScreen,
+ disposeTerminal,
+ };
+}
diff --git a/src/components/shell/types/types.ts b/src/components/shell/types/types.ts
new file mode 100644
index 0000000..b681ad7
--- /dev/null
+++ b/src/components/shell/types/types.ts
@@ -0,0 +1,73 @@
+import type { MutableRefObject, RefObject } from 'react';
+import type { FitAddon } from '@xterm/addon-fit';
+import type { Terminal } from '@xterm/xterm';
+import type { Project, ProjectSession } from '../../../types/app';
+
+export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
+
+export type ShellInitMessage = {
+ type: 'init';
+ projectPath: string;
+ sessionId: string | null;
+ hasSession: boolean;
+ provider: string;
+ cols: number;
+ rows: number;
+ initialCommand: string | null | undefined;
+ isPlainShell: boolean;
+};
+
+export type ShellResizeMessage = {
+ type: 'resize';
+ cols: number;
+ rows: number;
+};
+
+export type ShellInputMessage = {
+ type: 'input';
+ data: string;
+};
+
+export type ShellOutgoingMessage = ShellInitMessage | ShellResizeMessage | ShellInputMessage;
+
+export type ShellIncomingMessage =
+ | { type: 'output'; data: string }
+ | { type: 'auth_url'; url?: string }
+ | { type: 'url_open'; url?: string }
+ | { type: string; [key: string]: unknown };
+
+export type UseShellRuntimeOptions = {
+ selectedProject: Project | null | undefined;
+ selectedSession: ProjectSession | null | undefined;
+ initialCommand: string | null | undefined;
+ isPlainShell: boolean;
+ minimal: boolean;
+ autoConnect: boolean;
+ isRestarting: boolean;
+ onProcessComplete?: ((exitCode: number) => void) | null;
+};
+
+export type ShellSharedRefs = {
+ wsRef: MutableRefObject;
+ terminalRef: MutableRefObject;
+ fitAddonRef: MutableRefObject;
+ authUrlRef: MutableRefObject;
+ selectedProjectRef: MutableRefObject;
+ selectedSessionRef: MutableRefObject;
+ initialCommandRef: MutableRefObject;
+ isPlainShellRef: MutableRefObject;
+ onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;
+};
+
+export type UseShellRuntimeResult = {
+ terminalContainerRef: RefObject;
+ isConnected: boolean;
+ isInitialized: boolean;
+ isConnecting: boolean;
+ authUrl: string;
+ authUrlVersion: number;
+ connectToShell: () => void;
+ disconnectFromShell: () => void;
+ openAuthUrlInBrowser: (url?: string) => boolean;
+ copyAuthUrlToClipboard: (url?: string) => Promise;
+};
diff --git a/src/components/shell/utils/auth.ts b/src/components/shell/utils/auth.ts
new file mode 100644
index 0000000..d55041b
--- /dev/null
+++ b/src/components/shell/utils/auth.ts
@@ -0,0 +1,24 @@
+import type { ProjectSession } from '../../../types/app';
+import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
+
+export function isCodexLoginCommand(command: string | null | undefined): boolean {
+ return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
+}
+
+export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
+ if (isCodexLoginCommand(command)) {
+ return CODEX_DEVICE_AUTH_URL;
+ }
+
+ return authUrl;
+}
+
+export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
+ if (!session) {
+ return null;
+ }
+
+ return session.__provider === 'cursor'
+ ? session.name || 'Untitled Session'
+ : session.summary || 'New Session';
+}
\ No newline at end of file
diff --git a/src/components/shell/utils/socket.ts b/src/components/shell/utils/socket.ts
new file mode 100644
index 0000000..6cb18d6
--- /dev/null
+++ b/src/components/shell/utils/socket.ts
@@ -0,0 +1,32 @@
+import { IS_PLATFORM } from '../../../constants/config';
+import type { ShellIncomingMessage, ShellOutgoingMessage } from '../types/types';
+
+export function getShellWebSocketUrl(): string | null {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+
+ if (IS_PLATFORM) {
+ return `${protocol}//${window.location.host}/shell`;
+ }
+
+ const token = localStorage.getItem('auth-token');
+ if (!token) {
+ console.error('No authentication token found for Shell WebSocket connection');
+ return null;
+ }
+
+ return `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
+}
+
+export function parseShellMessage(payload: string): ShellIncomingMessage | null {
+ try {
+ return JSON.parse(payload) as ShellIncomingMessage;
+ } catch {
+ return null;
+ }
+}
+
+export function sendSocketMessage(ws: WebSocket | null, message: ShellOutgoingMessage): void {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(message));
+ }
+}
\ No newline at end of file
diff --git a/src/components/shell/utils/terminalStyles.ts b/src/components/shell/utils/terminalStyles.ts
new file mode 100644
index 0000000..387abe7
--- /dev/null
+++ b/src/components/shell/utils/terminalStyles.ts
@@ -0,0 +1,29 @@
+const XTERM_STYLE_ELEMENT_ID = 'shell-xterm-focus-style';
+
+const XTERM_FOCUS_STYLES = `
+ .xterm .xterm-screen {
+ outline: none !important;
+ }
+ .xterm:focus .xterm-screen {
+ outline: none !important;
+ }
+ .xterm-screen:focus {
+ outline: none !important;
+ }
+`;
+
+export function ensureXtermFocusStyles(): void {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ if (document.getElementById(XTERM_STYLE_ELEMENT_ID)) {
+ return;
+ }
+
+ const styleSheet = document.createElement('style');
+ styleSheet.id = XTERM_STYLE_ELEMENT_ID;
+ styleSheet.type = 'text/css';
+ styleSheet.innerText = XTERM_FOCUS_STYLES;
+ document.head.appendChild(styleSheet);
+}
\ No newline at end of file
diff --git a/src/components/shell/view/Shell.tsx b/src/components/shell/view/Shell.tsx
new file mode 100644
index 0000000..4fa563b
--- /dev/null
+++ b/src/components/shell/view/Shell.tsx
@@ -0,0 +1,162 @@
+import { useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import '@xterm/xterm/css/xterm.css';
+import type { Project, ProjectSession } from '../../../types/app';
+import { SHELL_RESTART_DELAY_MS } from '../constants/constants';
+import { useShellRuntime } from '../hooks/useShellRuntime';
+import { getSessionDisplayName } from '../utils/auth';
+import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
+import ShellEmptyState from './subcomponents/ShellEmptyState';
+import ShellHeader from './subcomponents/ShellHeader';
+import ShellMinimalView from './subcomponents/ShellMinimalView';
+
+type ShellProps = {
+ selectedProject?: Project | null;
+ selectedSession?: ProjectSession | null;
+ initialCommand?: string | null;
+ isPlainShell?: boolean;
+ onProcessComplete?: ((exitCode: number) => void) | null;
+ minimal?: boolean;
+ autoConnect?: boolean;
+ isActive?: boolean;
+};
+
+export default function Shell({
+ selectedProject = null,
+ selectedSession = null,
+ initialCommand = null,
+ isPlainShell = false,
+ onProcessComplete = null,
+ minimal = false,
+ autoConnect = false,
+ isActive,
+}: ShellProps) {
+ const { t } = useTranslation('chat');
+ const [isRestarting, setIsRestarting] = useState(false);
+
+ // Keep the public API stable for existing callers that still pass `isActive`.
+ void isActive;
+
+ const {
+ terminalContainerRef,
+ isConnected,
+ isInitialized,
+ isConnecting,
+ authUrl,
+ authUrlVersion,
+ connectToShell,
+ disconnectFromShell,
+ openAuthUrlInBrowser,
+ copyAuthUrlToClipboard,
+ } = useShellRuntime({
+ selectedProject,
+ selectedSession,
+ initialCommand,
+ isPlainShell,
+ minimal,
+ autoConnect,
+ isRestarting,
+ onProcessComplete,
+ });
+
+ const sessionDisplayName = useMemo(() => getSessionDisplayName(selectedSession), [selectedSession]);
+ const sessionDisplayNameShort = useMemo(
+ () => (sessionDisplayName ? sessionDisplayName.slice(0, 30) : null),
+ [sessionDisplayName],
+ );
+ const sessionDisplayNameLong = useMemo(
+ () => (sessionDisplayName ? sessionDisplayName.slice(0, 50) : null),
+ [sessionDisplayName],
+ );
+
+ const handleRestartShell = useCallback(() => {
+ setIsRestarting(true);
+ window.setTimeout(() => {
+ setIsRestarting(false);
+ }, SHELL_RESTART_DELAY_MS);
+ }, []);
+
+ if (!selectedProject) {
+ return (
+
+ );
+ }
+
+ if (minimal) {
+ return (
+
+ );
+ }
+
+ const readyDescription = isPlainShell
+ ? t('shell.runCommand', {
+ command: initialCommand || t('shell.defaultCommand'),
+ projectName: selectedProject.displayName,
+ })
+ : selectedSession
+ ? t('shell.resumeSession', { displayName: sessionDisplayNameLong })
+ : t('shell.startSession');
+
+ const connectingDescription = isPlainShell
+ ? t('shell.runCommand', {
+ command: initialCommand || t('shell.defaultCommand'),
+ projectName: selectedProject.displayName,
+ })
+ : t('shell.startCli', { projectName: selectedProject.displayName });
+
+ const overlayMode = !isInitialized ? 'loading' : isConnecting ? 'connecting' : !isConnected ? 'connect' : null;
+ const overlayDescription = overlayMode === 'connecting' ? connectingDescription : readyDescription;
+
+ return (
+
+
+
+
+
+
+ {overlayMode && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/shell/view/subcomponents/ShellConnectionOverlay.tsx b/src/components/shell/view/subcomponents/ShellConnectionOverlay.tsx
new file mode 100644
index 0000000..32b2249
--- /dev/null
+++ b/src/components/shell/view/subcomponents/ShellConnectionOverlay.tsx
@@ -0,0 +1,59 @@
+type ShellConnectionOverlayProps = {
+ mode: 'loading' | 'connect' | 'connecting';
+ description: string;
+ loadingLabel: string;
+ connectLabel: string;
+ connectTitle: string;
+ connectingLabel: string;
+ onConnect: () => void;
+};
+
+export default function ShellConnectionOverlay({
+ mode,
+ description,
+ loadingLabel,
+ connectLabel,
+ connectTitle,
+ connectingLabel,
+ onConnect,
+}: ShellConnectionOverlayProps) {
+ if (mode === 'loading') {
+ return (
+
+ );
+ }
+
+ if (mode === 'connect') {
+ return (
+
+
+
+
+
+
+ {connectLabel}
+
+
{description}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/shell/view/subcomponents/ShellEmptyState.tsx b/src/components/shell/view/subcomponents/ShellEmptyState.tsx
new file mode 100644
index 0000000..34e2bb1
--- /dev/null
+++ b/src/components/shell/view/subcomponents/ShellEmptyState.tsx
@@ -0,0 +1,25 @@
+type ShellEmptyStateProps = {
+ title: string;
+ description: string;
+};
+
+export default function ShellEmptyState({ title, description }: ShellEmptyStateProps) {
+ return (
+
+
+
+
{title}
+
{description}
+
+
+ );
+}
diff --git a/src/components/shell/view/subcomponents/ShellHeader.tsx b/src/components/shell/view/subcomponents/ShellHeader.tsx
new file mode 100644
index 0000000..56131fd
--- /dev/null
+++ b/src/components/shell/view/subcomponents/ShellHeader.tsx
@@ -0,0 +1,87 @@
+type ShellHeaderProps = {
+ isConnected: boolean;
+ isInitialized: boolean;
+ isRestarting: boolean;
+ hasSession: boolean;
+ sessionDisplayNameShort: string | null;
+ onDisconnect: () => void;
+ onRestart: () => void;
+ statusNewSessionText: string;
+ statusInitializingText: string;
+ statusRestartingText: string;
+ disconnectLabel: string;
+ disconnectTitle: string;
+ restartLabel: string;
+ restartTitle: string;
+ disableRestart: boolean;
+};
+
+export default function ShellHeader({
+ isConnected,
+ isInitialized,
+ isRestarting,
+ hasSession,
+ sessionDisplayNameShort,
+ onDisconnect,
+ onRestart,
+ statusNewSessionText,
+ statusInitializingText,
+ statusRestartingText,
+ disconnectLabel,
+ disconnectTitle,
+ restartLabel,
+ restartTitle,
+ disableRestart,
+}: ShellHeaderProps) {
+ return (
+
+
+
+
+
+ {hasSession && sessionDisplayNameShort && (
+
({sessionDisplayNameShort}...)
+ )}
+
+ {!hasSession &&
{statusNewSessionText} }
+
+ {!isInitialized &&
{statusInitializingText} }
+
+ {isRestarting &&
{statusRestartingText} }
+
+
+
+ {isConnected && (
+
+
+
+
+ {disconnectLabel}
+
+ )}
+
+
+
+
+
+ {restartLabel}
+
+
+
+
+ );
+}
diff --git a/src/components/shell/view/subcomponents/ShellMinimalView.tsx b/src/components/shell/view/subcomponents/ShellMinimalView.tsx
new file mode 100644
index 0000000..bdd2d40
--- /dev/null
+++ b/src/components/shell/view/subcomponents/ShellMinimalView.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useMemo, useState } from 'react';
+import type { RefObject } from 'react';
+import type { AuthCopyStatus } from '../../types/types';
+import { resolveAuthUrlForDisplay } from '../../utils/auth';
+
+type ShellMinimalViewProps = {
+ terminalContainerRef: RefObject;
+ authUrl: string;
+ authUrlVersion: number;
+ initialCommand: string | null | undefined;
+ isConnected: boolean;
+ openAuthUrlInBrowser: (url: string) => boolean;
+ copyAuthUrlToClipboard: (url: string) => Promise;
+};
+
+export default function ShellMinimalView({
+ terminalContainerRef,
+ authUrl,
+ authUrlVersion,
+ initialCommand,
+ isConnected,
+ openAuthUrlInBrowser,
+ copyAuthUrlToClipboard,
+}: ShellMinimalViewProps) {
+ const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
+ const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
+
+ const displayAuthUrl = useMemo(
+ () => resolveAuthUrlForDisplay(initialCommand, authUrl),
+ [authUrl, initialCommand],
+ );
+
+ // Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
+ useEffect(() => {
+ setAuthUrlCopyStatus('idle');
+ setIsAuthPanelHidden(false);
+ }, [authUrlVersion, displayAuthUrl, isConnected]);
+
+ const hasAuthUrl = Boolean(displayAuthUrl);
+ const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
+ const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
+
+ return (
+
+
+
+ {showMobileAuthPanel && (
+
+
+
+
Open or copy the login URL:
+
setIsAuthPanelHidden(true)}
+ className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
+ >
+ Hide
+
+
+
+
event.currentTarget.select()}
+ className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
+ aria-label="Authentication URL"
+ />
+
+
+ {
+ openAuthUrlInBrowser(displayAuthUrl);
+ }}
+ className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
+ >
+ Open URL
+
+
+ {
+ const copied = await copyAuthUrlToClipboard(displayAuthUrl);
+ setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
+ }}
+ className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
+ >
+ {authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
+
+
+
+
+ )}
+
+ {showMobileAuthPanelToggle && (
+
+ setIsAuthPanelHidden(false)}
+ className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
+ >
+ Show login URL
+
+
+ )}
+
+ );
+}
diff --git a/src/components/modals/VersionUpgradeModal.tsx b/src/components/sidebar/view/modals/VersionUpgradeModal.tsx
similarity index 97%
rename from src/components/modals/VersionUpgradeModal.tsx
rename to src/components/sidebar/view/modals/VersionUpgradeModal.tsx
index e5beeb0..bdf765b 100644
--- a/src/components/modals/VersionUpgradeModal.tsx
+++ b/src/components/sidebar/view/modals/VersionUpgradeModal.tsx
@@ -1,8 +1,9 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
-import { authenticatedFetch } from "../../utils/api";
-import { ReleaseInfo } from "../../types/sharedTypes";
-import type { InstallMode } from "../../hooks/useVersionCheck";
+import { authenticatedFetch } from "../../../../utils/api";
+import { ReleaseInfo } from "../../../../types/sharedTypes";
+import { copyTextToClipboard } from "../../../../utils/clipboard";
+import type { InstallMode } from "../../../../hooks/useVersionCheck";
interface VersionUpgradeModalProps {
isOpen: boolean;
@@ -28,7 +29,7 @@ export default function VersionUpgradeModal({
const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState('');
-
+
const handleUpdateNow = useCallback(async () => {
setIsUpdating(true);
setUpdateOutput('Starting update...\n');
@@ -176,9 +177,7 @@ export default function VersionUpgradeModal({
{!updateOutput && (
<>
{
- navigator.clipboard.writeText(upgradeCommand);
- }}
+ onClick={() => copyTextToClipboard(upgradeCommand)}
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"
>
{t('versionUpdate.buttons.copyCommand')}
diff --git a/src/components/sidebar/view/subcomponents/SidebarModals.tsx b/src/components/sidebar/view/subcomponents/SidebarModals.tsx
index 8a63321..a102878 100644
--- a/src/components/sidebar/view/subcomponents/SidebarModals.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarModals.tsx
@@ -4,8 +4,8 @@ import { AlertTriangle, Trash2 } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../ui/button';
import ProjectCreationWizard from '../../../ProjectCreationWizard';
-import Settings from '../../../Settings';
-import VersionUpgradeModal from '../../../modals/VersionUpgradeModal';
+import Settings from '../../../settings/view/Settings';
+import VersionUpgradeModal from '../modals/VersionUpgradeModal';
import type { Project } from '../../../../types/app';
import type { ReleaseInfo } from '../../../../types/sharedTypes';
import type { InstallMode } from '../../../../hooks/useVersionCheck';
diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
index eb62ddb..6149aba 100644
--- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx
@@ -7,7 +7,7 @@ import { formatTimeAgo } from '../../../../utils/dateUtils';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type { SessionWithProvider, TouchHandlerFactory } from '../../types/types';
import { createSessionViewModel } from '../../utils/utils';
-import SessionProviderLogo from '../../../SessionProviderLogo';
+import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
type SidebarSessionItemProps = {
project: Project;
diff --git a/src/components/standalone-shell/view/StandaloneShell.tsx b/src/components/standalone-shell/view/StandaloneShell.tsx
new file mode 100644
index 0000000..11cc1bd
--- /dev/null
+++ b/src/components/standalone-shell/view/StandaloneShell.tsx
@@ -0,0 +1,74 @@
+import { useCallback, useState } from 'react';
+import type { Project, ProjectSession } from '../../../types/app';
+import Shell from '../../shell/view/Shell';
+import StandaloneShellEmptyState from './subcomponents/StandaloneShellEmptyState';
+import StandaloneShellHeader from './subcomponents/StandaloneShellHeader';
+
+type StandaloneShellProps = {
+ project?: Project | null;
+ session?: ProjectSession | null;
+ command?: string | null;
+ isPlainShell?: boolean | null;
+ autoConnect?: boolean;
+ onComplete?: ((exitCode: number) => void) | null;
+ onClose?: (() => void) | null;
+ title?: string | null;
+ className?: string;
+ showHeader?: boolean;
+ compact?: boolean;
+ minimal?: boolean;
+};
+
+export default function StandaloneShell({
+ project = null,
+ session = null,
+ command = null,
+ isPlainShell = null,
+ autoConnect = true,
+ onComplete = null,
+ onClose = null,
+ title = null,
+ className = '',
+ showHeader = true,
+ compact = false,
+ minimal = false,
+}: StandaloneShellProps) {
+ const [isCompleted, setIsCompleted] = useState(false);
+
+ // Keep `compact` in the public API for compatibility with existing callers.
+ void compact;
+
+ const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : command !== null;
+
+ const handleProcessComplete = useCallback(
+ (exitCode: number) => {
+ setIsCompleted(true);
+ onComplete?.(exitCode);
+ },
+ [onComplete],
+ );
+
+ if (!project) {
+ return ;
+ }
+
+ return (
+
+ {!minimal && showHeader && title && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/standalone-shell/view/subcomponents/StandaloneShellEmptyState.tsx b/src/components/standalone-shell/view/subcomponents/StandaloneShellEmptyState.tsx
new file mode 100644
index 0000000..1f9cf75
--- /dev/null
+++ b/src/components/standalone-shell/view/subcomponents/StandaloneShellEmptyState.tsx
@@ -0,0 +1,24 @@
+type StandaloneShellEmptyStateProps = {
+ className: string;
+};
+
+export default function StandaloneShellEmptyState({ className }: StandaloneShellEmptyStateProps) {
+ return (
+
+
+
+
No Project Selected
+
A project is required to open a shell
+
+
+ );
+}
diff --git a/src/components/standalone-shell/view/subcomponents/StandaloneShellHeader.tsx b/src/components/standalone-shell/view/subcomponents/StandaloneShellHeader.tsx
new file mode 100644
index 0000000..e988a92
--- /dev/null
+++ b/src/components/standalone-shell/view/subcomponents/StandaloneShellHeader.tsx
@@ -0,0 +1,30 @@
+type StandaloneShellHeaderProps = {
+ title: string;
+ isCompleted: boolean;
+ onClose?: (() => void) | null;
+};
+
+export default function StandaloneShellHeader({
+ title,
+ isCompleted,
+ onClose = null,
+}: StandaloneShellHeaderProps) {
+ return (
+
+
+
+
{title}
+ {isCompleted && (Completed) }
+
+
+ {onClose && (
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts
new file mode 100644
index 0000000..1fd9ed5
--- /dev/null
+++ b/src/utils/clipboard.ts
@@ -0,0 +1,50 @@
+function fallbackCopyToClipboard(text: string): boolean {
+ if (!text || typeof document === 'undefined') {
+ return false;
+ }
+
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ textarea.style.pointerEvents = 'none';
+
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+
+ let copied = false;
+ try {
+ copied = document.execCommand('copy');
+ } catch {
+ copied = false;
+ } finally {
+ document.body.removeChild(textarea);
+ }
+
+ return copied;
+}
+
+export async function copyTextToClipboard(text: string): Promise {
+ if (!text) {
+ return false;
+ }
+
+ let copied = false;
+
+ try {
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ copied = true;
+ }
+ } catch {
+ copied = false;
+ }
+
+ if (!copied) {
+ copied = fallbackCopyToClipboard(text);
+ }
+
+ return copied;
+}
\ No newline at end of file
diff --git a/src/utils/whisper.js b/src/utils/whisper.js
deleted file mode 100755
index d1bd673..0000000
--- a/src/utils/whisper.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { api } from './api';
-
-export async function transcribeWithWhisper(audioBlob, onStatusChange) {
- const formData = new FormData();
- const fileName = `recording_${Date.now()}.webm`;
- const file = new File([audioBlob], fileName, { type: audioBlob.type });
-
- formData.append('audio', file);
-
- const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
- formData.append('mode', whisperMode);
-
- try {
- // Start with transcribing state
- if (onStatusChange) {
- onStatusChange('transcribing');
- }
-
- const response = await api.transcribe(formData);
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(
- errorData.error ||
- `Transcription error: ${response.status} ${response.statusText}`
- );
- }
-
- const data = await response.json();
- return data.text || '';
- } catch (error) {
- if (error.name === 'TypeError' && error.message.includes('fetch')) {
- throw new Error('Cannot connect to server. Please ensure the backend is running.');
- }
- throw error;
- }
- }
\ No newline at end of file