Feat: Refine design language and use theme tokens across most pages.

This commit is contained in:
simosmik
2026-02-16 13:17:47 +00:00
parent 42f13e151c
commit afe1be7fca
21 changed files with 1209 additions and 880 deletions

View File

@@ -538,7 +538,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
}; };
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously) // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';

View File

@@ -3,7 +3,7 @@ import React from 'react';
function DiffViewer({ diff, fileName, isMobile, wrapText }) { function DiffViewer({ diff, fileName, isMobile, wrapText }) {
if (!diff) { if (!diff) {
return ( return (
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm"> <div className="p-4 text-center text-muted-foreground text-sm">
No diff available No diff available
</div> </div>
); );
@@ -17,13 +17,13 @@ function DiffViewer({ diff, fileName, isMobile, wrapText }) {
return ( return (
<div <div
key={index} key={index}
className={`font-mono text-xs p-2 ${ className={`font-mono text-xs px-3 py-0.5 ${
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto' isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
} ${ } ${
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' : isAddition ? 'bg-green-50 dark:bg-green-950/50 text-green-700 dark:text-green-300' :
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' : isDeletion ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' :
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' : isHeader ? 'bg-primary/5 text-primary' :
'text-gray-600 dark:text-gray-400' 'text-muted-foreground/70'
}`} }`}
> >
{line} {line}

View File

@@ -3,12 +3,263 @@ import { useTranslation } from 'react-i18next';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X } from 'lucide-react'; 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 { cn } from '../lib/utils';
import CodeEditor from './CodeEditor'; import CodeEditor from './CodeEditor';
import ImageViewer from './ImageViewer'; import ImageViewer from './ImageViewer';
import { api } from '../utils/api'; 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 }) { function FileTree({ selectedProject }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
@@ -16,7 +267,7 @@ function FileTree({ selectedProject }) {
const [expandedDirs, setExpandedDirs] = useState(new Set()); const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
const [selectedImage, setSelectedImage] = useState(null); const [selectedImage, setSelectedImage] = useState(null);
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact' const [viewMode, setViewMode] = useState('detailed');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState([]); const [filteredFiles, setFilteredFiles] = useState([]);
@@ -26,7 +277,6 @@ function FileTree({ selectedProject }) {
} }
}, [selectedProject]); }, [selectedProject]);
// Load view mode preference from localStorage
useEffect(() => { useEffect(() => {
const savedViewMode = localStorage.getItem('file-tree-view-mode'); const savedViewMode = localStorage.getItem('file-tree-view-mode');
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) { if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
@@ -34,7 +284,6 @@ function FileTree({ selectedProject }) {
} }
}, []); }, []);
// Filter files based on search query
useEffect(() => { useEffect(() => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
setFilteredFiles(files); setFilteredFiles(files);
@@ -42,7 +291,6 @@ function FileTree({ selectedProject }) {
const filtered = filterFiles(files, searchQuery.toLowerCase()); const filtered = filterFiles(files, searchQuery.toLowerCase());
setFilteredFiles(filtered); setFilteredFiles(filtered);
// Auto-expand directories that contain matches
const expandMatches = (items) => { const expandMatches = (items) => {
items.forEach(item => { items.forEach(item => {
if (item.type === 'directory' && item.children && item.children.length > 0) { if (item.type === 'directory' && item.children && item.children.length > 0) {
@@ -55,7 +303,6 @@ function FileTree({ selectedProject }) {
} }
}, [files, searchQuery]); }, [files, searchQuery]);
// Recursively filter files and directories based on search query
const filterFiles = (items, query) => { const filterFiles = (items, query) => {
return items.reduce((filtered, item) => { return items.reduce((filtered, item) => {
const matchesName = item.name.toLowerCase().includes(query); const matchesName = item.name.toLowerCase().includes(query);
@@ -65,9 +312,6 @@ function FileTree({ selectedProject }) {
filteredChildren = filterFiles(item.children, query); filteredChildren = filterFiles(item.children, query);
} }
// Include item if:
// 1. It matches the search query, or
// 2. It's a directory with matching children
if (matchesName || filteredChildren.length > 0) { if (matchesName || filteredChildren.length > 0) {
filtered.push({ filtered.push({
...item, ...item,
@@ -83,14 +327,14 @@ function FileTree({ selectedProject }) {
setLoading(true); setLoading(true);
try { try {
const response = await api.getFiles(selectedProject.name); const response = await api.getFiles(selectedProject.name);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error('❌ File fetch failed:', response.status, errorText); console.error('❌ File fetch failed:', response.status, errorText);
setFiles([]); setFiles([]);
return; return;
} }
const data = await response.json(); const data = await response.json();
setFiles(data); setFiles(data);
} catch (error) { } catch (error) {
@@ -111,13 +355,11 @@ function FileTree({ selectedProject }) {
setExpandedDirs(newExpanded); setExpandedDirs(newExpanded);
}; };
// Change view mode and save preference
const changeViewMode = (mode) => { const changeViewMode = (mode) => {
setViewMode(mode); setViewMode(mode);
localStorage.setItem('file-tree-view-mode', mode); localStorage.setItem('file-tree-view-mode', mode);
}; };
// Format file size
const formatFileSize = (bytes) => { const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B'; if (!bytes || bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -126,7 +368,6 @@ function FileTree({ selectedProject }) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}; };
// Format date as relative time
const formatRelativeTime = (date) => { const formatRelativeTime = (date) => {
if (!date) return '-'; if (!date) return '-';
const now = new Date(); const now = new Date();
@@ -140,65 +381,6 @@ function FileTree({ selectedProject }) {
return past.toLocaleDateString(); return past.toLocaleDateString();
}; };
const renderFileTree = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<Button
variant="ghost"
className={cn(
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent",
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
// Open image in viewer
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
// Open file in editor
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="flex items-center gap-2 min-w-0 w-full">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
</Button>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
item.children.length > 0 && (
<div>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
));
};
const isImageFile = (filename) => { const isImageFile = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase(); const ext = filename.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']; const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
@@ -206,208 +388,289 @@ function FileTree({ selectedProject }) {
}; };
const getFileIcon = (filename) => { const getFileIcon = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase(); const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE, color)} />;
const codeExtensions = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'cpp', 'c', 'php', 'rb', 'go', 'rs']; };
const docExtensions = ['md', 'txt', 'doc', 'pdf'];
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']; // ── Click handler shared across all view modes ──
const handleItemClick = (item) => {
if (codeExtensions.includes(ext)) { if (item.type === 'directory') {
return <FileCode className="w-4 h-4 text-green-500 flex-shrink-0" />; toggleDirectory(item.path);
} else if (docExtensions.includes(ext)) { } else if (isImageFile(item.name)) {
return <FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />; setSelectedImage({
} else if (imageExtensions.includes(ext)) { name: item.name,
return <File className="w-4 h-4 text-purple-500 flex-shrink-0" />; path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else { } else {
return <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />; setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} }
}; };
// Render detailed view with table-like layout // ── Indent guide + folder/file icon rendering ──
const renderIndentGuides = (level) => {
if (level === 0) return null;
return (
<span className="flex items-center flex-shrink-0" aria-hidden="true">
{Array.from({ length: level }).map((_, i) => (
<span
key={i}
className="inline-block w-4 h-full border-l border-border/50"
/>
))}
</span>
);
};
const renderItemIcons = (item) => {
const isDir = item.type === 'directory';
const isOpen = expandedDirs.has(item.path);
if (isDir) {
return (
<span className="flex items-center gap-0.5 flex-shrink-0">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
isOpen && 'rotate-90'
)}
/>
{isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</span>
);
}
return (
<span className="flex items-center flex-shrink-0 ml-[18px]">
{getFileIcon(item.name)}
</span>
);
};
// ─── 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 (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm',
'hover:bg-accent/60 transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
{isDir && isOpen && item.children && item.children.length > 0 && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Detailed View ────────────────────────────────────────────────
const renderDetailedView = (items, level = 0) => { const renderDetailedView = (items, level = 0) => {
return items.map((item) => ( return items.map((item) => {
<div key={item.path} className="select-none"> const isDir = item.type === 'directory';
<div const isOpen = isDir && expandedDirs.has(item.path);
className={cn(
"grid grid-cols-12 gap-2 p-2 hover:bg-accent cursor-pointer items-center", return (
)} <div key={item.path} className="select-none">
style={{ paddingLeft: `${level * 16 + 12}px` }} <div
onClick={() => { className={cn(
if (item.type === 'directory') { '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',
toggleDirectory(item.path); isDir && isOpen && 'border-l-2 border-primary/30',
} else if (isImageFile(item.name)) { isDir && !isOpen && 'border-l-2 border-transparent',
setSelectedImage({ !isDir && 'border-l-2 border-transparent',
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="col-span-5 flex items-center gap-2 min-w-0">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)} )}
<span className="text-sm truncate text-foreground"> style={{ paddingLeft: `${level * 16 + 4}px` }}
{item.name} onClick={() => handleItemClick(item)}
</span> >
</div> <div className="col-span-5 flex items-center gap-1.5 min-w-0">
<div className="col-span-2 text-sm text-muted-foreground"> {renderItemIcons(item)}
{item.type === 'file' ? formatFileSize(item.size) : '-'} <span className={cn(
</div> 'text-[13px] leading-tight truncate',
<div className="col-span-3 text-sm text-muted-foreground"> isDir ? 'font-medium text-foreground' : 'text-foreground/90'
{formatRelativeTime(item.modified)} )}>
</div> {item.name}
<div className="col-span-2 text-sm text-muted-foreground font-mono"> </span>
{item.permissionsRwx || '-'} </div>
<div className="col-span-2 text-xs text-muted-foreground tabular-nums">
{item.type === 'file' ? formatFileSize(item.size) : ''}
</div>
<div className="col-span-3 text-xs text-muted-foreground">
{formatRelativeTime(item.modified)}
</div>
<div className="col-span-2 text-xs text-muted-foreground font-mono">
{item.permissionsRwx || ''}
</div>
</div> </div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderDetailedView(item.children, level + 1)}
</div>
)}
</div> </div>
);
{item.type === 'directory' && });
expandedDirs.has(item.path) &&
item.children &&
renderDetailedView(item.children, level + 1)}
</div>
));
}; };
// Render compact view with inline details // ─── Compact View ──────────────────────────────────────────────────
const renderCompactView = (items, level = 0) => { const renderCompactView = (items, level = 0) => {
return items.map((item) => ( return items.map((item) => {
<div key={item.path} className="select-none"> const isDir = item.type === 'directory';
<div const isOpen = isDir && expandedDirs.has(item.path);
className={cn(
"flex items-center justify-between p-2 hover:bg-accent cursor-pointer", return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-shrink-0 ml-2">
{item.type === 'file' && (
<>
<span className="tabular-nums">{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderCompactView(item.children, level + 1)}
</div>
)} )}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
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 {
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="flex items-center gap-2 min-w-0">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{item.type === 'file' && (
<>
<span>{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div> </div>
);
{item.type === 'directory' && });
expandedDirs.has(item.path) &&
item.children &&
renderCompactView(item.children, level + 1)}
</div>
));
}; };
// ─── Loading state ─────────────────────────────────────────────────
if (loading) { if (loading) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400"> <div className="text-muted-foreground text-sm">
{t('fileTree.loading')} {t('fileTree.loading')}
</div> </div>
</div> </div>
); );
} }
// ─── Main render ───────────────────────────────────────────────────
return ( return (
<div className="h-full flex flex-col bg-card"> <div className="h-full flex flex-col bg-card">
{/* Header with Search and View Mode Toggle */} {/* Header */}
<div className="p-4 border-b border-border space-y-3"> <div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3> <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<div className="flex gap-1"> {t('fileTree.files')}
</h3>
<div className="flex gap-0.5">
<Button <Button
variant={viewMode === 'simple' ? 'default' : 'ghost'} variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm" size="sm"
className="h-8 w-8 p-0" className="h-7 w-7 p-0"
onClick={() => changeViewMode('simple')} onClick={() => changeViewMode('simple')}
title={t('fileTree.simpleView')} title={t('fileTree.simpleView')}
> >
<List className="w-4 h-4" /> <List className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={viewMode === 'compact' ? 'default' : 'ghost'} variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm" size="sm"
className="h-8 w-8 p-0" className="h-7 w-7 p-0"
onClick={() => changeViewMode('compact')} onClick={() => changeViewMode('compact')}
title={t('fileTree.compactView')} title={t('fileTree.compactView')}
> >
<Eye className="w-4 h-4" /> <Eye className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'} variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm" size="sm"
className="h-8 w-8 p-0" className="h-7 w-7 p-0"
onClick={() => changeViewMode('detailed')} onClick={() => changeViewMode('detailed')}
title={t('fileTree.detailedView')} title={t('fileTree.detailedView')}
> >
<TableProperties className="w-4 h-4" /> <TableProperties className="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
{/* Search Bar */} {/* Search Bar */}
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder={t('fileTree.searchPlaceholder')} placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm" className="pl-7 pr-7 h-7 text-xs"
/> />
{searchQuery && ( {searchQuery && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent" className="absolute right-0.5 top-1/2 transform -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery('')}
title={t('fileTree.clearSearch')} title={t('fileTree.clearSearch')}
> >
@@ -419,8 +682,8 @@ function FileTree({ selectedProject }) {
{/* Column Headers for Detailed View */} {/* Column Headers for Detailed View */}
{viewMode === 'detailed' && filteredFiles.length > 0 && ( {viewMode === 'detailed' && filteredFiles.length > 0 && (
<div className="px-4 pt-2 pb-1 border-b border-border"> <div className="px-3 pt-1.5 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground"> <div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div> <div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div> <div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div> <div className="col-span-3">{t('fileTree.modified')}</div>
@@ -429,7 +692,7 @@ function FileTree({ selectedProject }) {
</div> </div>
)} )}
<ScrollArea className="flex-1 p-4"> <ScrollArea className="flex-1 px-2 py-1">
{files.length === 0 ? ( {files.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3"> <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
@@ -451,14 +714,14 @@ function FileTree({ selectedProject }) {
</p> </p>
</div> </div>
) : ( ) : (
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}> <div>
{viewMode === 'simple' && renderFileTree(filteredFiles)} {viewMode === 'simple' && renderFileTree(filteredFiles)}
{viewMode === 'compact' && renderCompactView(filteredFiles)} {viewMode === 'compact' && renderCompactView(filteredFiles)}
{viewMode === 'detailed' && renderDetailedView(filteredFiles)} {viewMode === 'detailed' && renderDetailedView(filteredFiles)}
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
{/* Code Editor Modal */} {/* Code Editor Modal */}
{selectedFile && ( {selectedFile && (
<CodeEditor <CodeEditor
@@ -467,7 +730,7 @@ function FileTree({ selectedProject }) {
projectPath={selectedFile.projectPath} projectPath={selectedFile.projectPath}
/> />
)} )}
{/* Image Viewer Modal */} {/* Image Viewer Modal */}
{selectedImage && ( {selectedImage && (
<ImageViewer <ImageViewer
@@ -479,4 +742,4 @@ function FileTree({ selectedProject }) {
); );
} }
export default FileTree; export default FileTree;

View File

@@ -640,36 +640,36 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
const renderCommitItem = (commit) => { const renderCommitItem = (commit) => {
const isExpanded = expandedCommits.has(commit.hash); const isExpanded = expandedCommits.has(commit.hash);
const diff = commitDiffs[commit.hash]; const diff = commitDiffs[commit.hash];
return ( return (
<div key={commit.hash} className="border-b border-gray-200 dark:border-gray-700 last:border-0"> <div key={commit.hash} className="border-b border-border last:border-0">
<div <div
className="flex items-start p-3 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer" className="flex items-start p-3 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => toggleCommitExpanded(commit.hash)} onClick={() => toggleCommitExpanded(commit.hash)}
> >
<div className="mr-2 mt-1 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"> <div className="mr-2 mt-1 p-0.5 hover:bg-accent rounded">
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />} {isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate"> <p className="text-sm font-medium text-foreground truncate">
{commit.message} {commit.message}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-muted-foreground mt-1">
{commit.author} {commit.date} {commit.author} {commit.date}
</p> </p>
</div> </div>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 flex-shrink-0"> <span className="text-xs font-mono text-muted-foreground/60 flex-shrink-0">
{commit.hash.substring(0, 7)} {commit.hash.substring(0, 7)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{isExpanded && diff && ( {isExpanded && diff && (
<div className="bg-gray-50 dark:bg-gray-900"> <div className="bg-muted/50">
<div className="max-h-96 overflow-y-auto p-2"> <div className="max-h-96 overflow-y-auto p-2">
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2"> <div className="text-xs font-mono text-muted-foreground mb-2">
{commit.stats} {commit.stats}
</div> </div>
<DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} /> <DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
@@ -684,22 +684,20 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
const isExpanded = expandedFiles.has(filePath); const isExpanded = expandedFiles.has(filePath);
const isSelected = selectedFiles.has(filePath); const isSelected = selectedFiles.has(filePath);
const diff = gitDiff[filePath]; const diff = gitDiff[filePath];
return ( return (
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0"> <div key={filePath} className="border-b border-border last:border-0">
<div className={`flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}> <div className={`flex items-center hover:bg-accent/50 transition-colors ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => toggleFileSelected(filePath)} onChange={() => toggleFileSelected(filePath)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`} className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
/> />
<div <div className="flex items-center flex-1">
className="flex items-center flex-1"
>
<div <div
className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`} className={`p-0.5 hover:bg-accent rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleFileExpanded(filePath); toggleFileExpanded(filePath);
@@ -708,7 +706,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} /> <ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</div> </div>
<span <span
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 hover:underline`} className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-primary hover:underline`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleFileOpen(filePath); handleFileOpen(filePath);
@@ -722,16 +720,16 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setConfirmAction({ setConfirmAction({
type: 'discard', type: 'discard',
file: filePath, file: filePath,
message: `Discard all changes to "${filePath}"? This action cannot be undone.` message: `Discard all changes to "${filePath}"? This action cannot be undone.`
}); });
}} }}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`} 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" title="Discard changes"
> >
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} /> <Trash2 className="w-3 h-3" />
{isMobile && <span>Discard</span>} {isMobile && <span>Discard</span>}
</button> </button>
)} )}
@@ -739,25 +737,25 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setConfirmAction({ setConfirmAction({
type: 'delete', type: 'delete',
file: filePath, file: filePath,
message: `Delete untracked file "${filePath}"? This action cannot be undone.` message: `Delete untracked file "${filePath}"? This action cannot be undone.`
}); });
}} }}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`} 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" title="Delete untracked file"
> >
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} /> <Trash2 className="w-3 h-3" />
{isMobile && <span>Delete</span>} {isMobile && <span>Delete</span>}
</button> </button>
)} )}
<span <span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${ className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' : status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50' :
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' : status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50' :
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' : status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50' :
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600' 'bg-muted text-muted-foreground border-border'
}`} }`}
title={getStatusLabel(status)} title={getStatusLabel(status)}
> >
@@ -766,25 +764,25 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</div> </div>
</div> </div>
</div> </div>
<div className={`bg-gray-50 dark:bg-gray-900 transition-all duration-400 ease-in-out overflow-hidden ${ <div className={`bg-muted/50 transition-all duration-400 ease-in-out overflow-hidden ${
isExpanded && diff isExpanded && diff
? 'max-h-[600px] opacity-100 translate-y-0' ? 'max-h-[600px] opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-1' : 'max-h-0 opacity-0 -translate-y-1'
}`}> }`}>
{/* Operation header */} {/* Operation header */}
<div className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between p-2 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${ className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' : status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50' :
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' : status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50' :
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' : status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50' :
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600' 'bg-muted text-muted-foreground border-border'
}`} }`}
> >
{status} {status}
</span> </span>
<span className="text-sm font-medium text-gray-900 dark:text-white"> <span className="text-sm font-medium text-foreground">
{getStatusLabel(status)} {getStatusLabel(status)}
</span> </span>
</div> </div>
@@ -794,7 +792,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
e.stopPropagation(); e.stopPropagation();
setWrapText(!wrapText); setWrapText(!wrapText);
}} }}
className="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white" className="text-xs text-muted-foreground hover:text-foreground transition-colors"
title={wrapText ? "Switch to horizontal scroll" : "Switch to text wrap"} title={wrapText ? "Switch to horizontal scroll" : "Switch to text wrap"}
> >
{wrapText ? '↔️ Scroll' : '↩️ Wrap'} {wrapText ? '↔️ Scroll' : '↩️ Wrap'}
@@ -811,7 +809,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
if (!selectedProject) { if (!selectedProject) {
return ( return (
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400"> <div className="h-full flex items-center justify-center text-muted-foreground">
<p>Select a project to view source control</p> <p>Select a project to view source control</p>
</div> </div>
); );
@@ -820,13 +818,13 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
return ( return (
<div className="h-full flex flex-col bg-background"> <div className="h-full flex flex-col bg-background">
{/* Header */} {/* Header */}
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}> <div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setShowBranchDropdown(!showBranchDropdown)} onClick={() => setShowBranchDropdown(!showBranchDropdown)}
className={`flex items-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`} 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'}`}
> >
<GitBranch className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} /> <GitBranch className={`text-muted-foreground ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span> <span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
{/* Remote status indicators */} {/* Remote status indicators */}
@@ -838,47 +836,47 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</span> </span>
)} )}
{remoteStatus.behind > 0 && ( {remoteStatus.behind > 0 && (
<span className="text-blue-600 dark:text-blue-400" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}> <span className="text-primary" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
{remoteStatus.behind} {remoteStatus.behind}
</span> </span>
)} )}
{remoteStatus.isUpToDate && ( {remoteStatus.isUpToDate && (
<span className="text-gray-500 dark:text-gray-400" title="Up to date with remote"> <span className="text-muted-foreground" title="Up to date with remote">
</span> </span>
)} )}
</div> </div>
)} )}
</div> </div>
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
</button> </button>
{/* Branch Dropdown */} {/* Branch Dropdown */}
{showBranchDropdown && ( {showBranchDropdown && (
<div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50"> <div className="absolute top-full left-0 mt-1 w-64 bg-card rounded-xl shadow-lg border border-border z-50 overflow-hidden">
<div className="py-1 max-h-64 overflow-y-auto"> <div className="py-1 max-h-64 overflow-y-auto">
{branches.map(branch => ( {branches.map(branch => (
<button <button
key={branch} key={branch}
onClick={() => switchBranch(branch)} onClick={() => switchBranch(branch)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
branch === currentBranch ? 'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300' branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
}`} }`}
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{branch === currentBranch && <Check className="w-3 h-3 text-green-600 dark:text-green-400" />} {branch === currentBranch && <Check className="w-3 h-3 text-primary" />}
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span> <span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
</div> </div>
</button> </button>
))} ))}
</div> </div>
<div className="border-t border-gray-200 dark:border-gray-700 py-1"> <div className="border-t border-border py-1">
<button <button
onClick={() => { onClick={() => {
setShowNewBranchModal(true); setShowNewBranchModal(true);
setShowBranchDropdown(false); setShowBranchDropdown(false);
}} }}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center space-x-2" className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
<span>Create new branch</span> <span>Create new branch</span>
@@ -895,12 +893,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Publish button - show when branch doesn't exist on remote */} {/* Publish button - show when branch doesn't exist on remote */}
{!remoteStatus?.hasUpstream && ( {!remoteStatus?.hasUpstream && (
<button <button
onClick={() => setConfirmAction({ onClick={() => setConfirmAction({
type: 'publish', type: 'publish',
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?` message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
})} })}
disabled={isPublishing} disabled={isPublishing}
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1" className="px-2.5 py-1 text-xs 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}`} title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
> >
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} /> <Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
@@ -914,41 +912,41 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Pull button - show when behind (primary action) */} {/* Pull button - show when behind (primary action) */}
{remoteStatus.behind > 0 && ( {remoteStatus.behind > 0 && (
<button <button
onClick={() => setConfirmAction({ onClick={() => setConfirmAction({
type: 'pull', type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?` message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
})} })}
disabled={isPulling} disabled={isPulling}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1" className="px-2.5 py-1 text-xs 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}`} title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
> >
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} /> <Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span> <span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
</button> </button>
)} )}
{/* Push button - show when ahead (primary action when ahead only) */} {/* Push button - show when ahead (primary action when ahead only) */}
{remoteStatus.ahead > 0 && ( {remoteStatus.ahead > 0 && (
<button <button
onClick={() => setConfirmAction({ onClick={() => setConfirmAction({
type: 'push', type: 'push',
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?` message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
})} })}
disabled={isPushing} disabled={isPushing}
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1" className="px-2.5 py-1 text-xs 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}`} title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
> >
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} /> <Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span> <span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
</button> </button>
)} )}
{/* Fetch button - show when ahead only or when diverged (secondary action) */} {/* Fetch button - show when ahead only or when diverged (secondary action) */}
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
<button <button
onClick={handleFetch} onClick={handleFetch}
disabled={isFetching} disabled={isFetching}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1" className="px-2.5 py-1 text-xs bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Fetch from ${remoteStatus.remoteName}`} title={`Fetch from ${remoteStatus.remoteName}`}
> >
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
@@ -967,42 +965,43 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
fetchRemoteStatus(); fetchRemoteStatus();
}} }}
disabled={isLoading} disabled={isLoading}
className={`hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${isMobile ? 'p-1' : 'p-1.5'}`} className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
> >
<RefreshCw className={`${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} /> <RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
</button> </button>
</div> </div>
</div> </div>
{/* Git Repository Not Found Message */} {/* Git Repository Not Found Message */}
{gitStatus?.error ? ( {gitStatus?.error ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12"> <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground px-6 py-12">
<GitBranch className="w-20 h-20 mb-6 opacity-30" /> <div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-6">
<h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3> <GitBranch className="w-8 h-8 opacity-40" />
</div>
<h3 className="text-lg font-medium mb-3 text-center text-foreground">{gitStatus.error}</h3>
{gitStatus.details && ( {gitStatus.details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p> <p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
)} )}
{/* // ! This can be a custom component that can be reused for " Tip: Create a new project..." as well */} <div className="p-4 bg-primary/5 rounded-xl border border-primary/10 max-w-md">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md"> <p className="text-sm text-primary text-center">
<p className="text-sm text-blue-700 dark:text-blue-300 text-center"> <strong>Tip:</strong> Run <code className="bg-primary/10 px-2 py-1 rounded-md font-mono text-xs">git init</code> in your project directory to initialize git source control.
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<> <>
{/* Tab Navigation - Only show when git is available and no files expanded */} {/* Tab Navigation - Only show when git is available and no files expanded */}
<div className={`flex border-b border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out ${ <div className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
expandedFiles.size === 0 expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0' ? 'max-h-16 opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}> }`}>
<button <button
onClick={() => setActiveView('changes')} onClick={() => setActiveView('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes' activeView === 'changes'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400' ? 'text-primary border-b-2 border-primary'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@@ -1014,8 +1013,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
onClick={() => setActiveView('history')} onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${ className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history' activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400' ? 'text-primary border-b-2 border-primary'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@@ -1035,10 +1034,10 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}> }`}>
{isMobile && isCommitAreaCollapsed ? ( {isMobile && isCommitAreaCollapsed ? (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700"> <div className="px-4 py-2 border-b border-border/60">
<button <button
onClick={() => setIsCommitAreaCollapsed(false)} onClick={() => setIsCommitAreaCollapsed(false)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700" 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"
> >
<GitCommit className="w-4 h-4" /> <GitCommit className="w-4 h-4" />
<span>Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}</span> <span>Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}</span>
@@ -1048,27 +1047,27 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
) : ( ) : (
<> <>
{/* Commit Message Input */} {/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <div className="px-4 py-3 border-b border-border/60">
{/* Mobile collapse button */} {/* Mobile collapse button */}
{isMobile && ( {isMobile && (
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Commit Changes</span> <span className="text-sm font-medium text-foreground">Commit Changes</span>
<button <button
onClick={() => setIsCommitAreaCollapsed(true)} onClick={() => setIsCommitAreaCollapsed(true)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded" className="p-1 hover:bg-accent rounded-lg transition-colors"
> >
<ChevronDown className="w-4 h-4 rotate-180" /> <ChevronDown className="w-4 h-4 rotate-180" />
</button> </button>
</div> </div>
)} )}
<div className="relative"> <div className="relative">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={commitMessage} value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)} onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Message (Ctrl+Enter to commit)" placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20" 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" rows="3"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
@@ -1080,7 +1079,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<button <button
onClick={generateCommitMessage} onClick={generateCommitMessage}
disabled={selectedFiles.size === 0 || isGeneratingMessage} disabled={selectedFiles.size === 0 || isGeneratingMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed" className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Generate commit message" title="Generate commit message"
> >
{isGeneratingMessage ? ( {isGeneratingMessage ? (
@@ -1099,16 +1098,16 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</div> </div>
</div> </div>
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500"> <span className="text-xs text-muted-foreground">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span> </span>
<button <button
onClick={() => setConfirmAction({ onClick={() => setConfirmAction({
type: 'commit', type: 'commit',
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?` message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
})} })}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting} disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1" 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"
> >
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span> <span>{isCommitting ? 'Committing...' : 'Commit'}</span>
@@ -1123,12 +1122,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* File Selection Controls - Only show in changes view and when git is working and no files expanded */} {/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
{activeView === 'changes' && gitStatus && !gitStatus.error && ( {activeView === 'changes' && gitStatus && !gitStatus.error && (
<div className={`border-b border-gray-200 dark:border-gray-700 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${ <div className={`border-b border-border/60 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
expandedFiles.size === 0 expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0' ? 'max-h-16 opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}> }`}>
<span className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'text-xs' : 'text-xs'}`}> <span className="text-xs text-muted-foreground">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected {selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
</span> </span>
<div className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}> <div className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
@@ -1142,14 +1141,14 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
]); ]);
setSelectedFiles(allFiles); setSelectedFiles(allFiles);
}} }}
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`} className="text-xs text-primary hover:text-primary/80 transition-colors"
> >
{isMobile ? 'All' : 'Select All'} {isMobile ? 'All' : 'Select All'}
</button> </button>
<span className="text-gray-300 dark:text-gray-600">|</span> <span className="text-border">|</span>
<button <button
onClick={() => setSelectedFiles(new Set())} onClick={() => setSelectedFiles(new Set())}
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`} className="text-xs text-primary hover:text-primary/80 transition-colors"
> >
{isMobile ? 'None' : 'Deselect All'} {isMobile ? 'None' : 'Deselect All'}
</button> </button>
@@ -1159,42 +1158,42 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Status Legend Toggle - Hide on mobile by default */} {/* Status Legend Toggle - Hide on mobile by default */}
{!gitStatus?.error && !isMobile && ( {!gitStatus?.error && !isMobile && (
<div className="border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-border/60">
<button <button
onClick={() => setShowLegend(!showLegend)} onClick={() => setShowLegend(!showLegend)}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1" className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-xs text-muted-foreground flex items-center justify-center gap-1 transition-colors"
> >
<Info className="w-3 h-3" /> <Info className="w-3 h-3" />
<span>File Status Guide</span> <span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />} {showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button> </button>
{showLegend && ( {showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs"> <div className="px-4 py-3 bg-muted/30 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}> <div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs"> <span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800/50 font-bold text-[10px]">
M M
</span> </span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span> <span className="text-muted-foreground italic">Modified</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs"> <span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 rounded border border-green-200 dark:border-green-800/50 font-bold text-[10px]">
A A
</span> </span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span> <span className="text-muted-foreground italic">Added</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs"> <span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 rounded border border-red-200 dark:border-red-800/50 font-bold text-[10px]">
D D
</span> </span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span> <span className="text-muted-foreground italic">Deleted</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs"> <span className="inline-flex items-center justify-center w-5 h-5 bg-muted text-muted-foreground rounded border border-border font-bold text-[10px]">
U U
</span> </span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span> <span className="text-muted-foreground italic">Untracked</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1209,19 +1208,21 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" /> <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div> </div>
) : gitStatus?.hasCommits === false ? ( ) : gitStatus?.hasCommits === false ? (
<div className="flex flex-col items-center justify-center p-8 text-center"> <div className="flex flex-col items-center justify-center p-8 text-center">
<GitBranch className="w-16 h-16 mb-4 opacity-30 text-gray-400 dark:text-gray-500" /> <div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<h3 className="text-lg font-medium mb-2 text-gray-900 dark:text-white">No commits yet</h3> <GitBranch className="w-7 h-7 text-muted-foreground/50" />
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-md"> </div>
<h3 className="text-lg font-medium mb-2 text-foreground">No commits yet</h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
This repository doesn't have any commits yet. Create your first commit to start tracking changes. This repository doesn't have any commits yet. Create your first commit to start tracking changes.
</p> </p>
<button <button
onClick={createInitialCommit} onClick={createInitialCommit}
disabled={isCreatingInitialCommit} disabled={isCreatingInitialCommit}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" 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 ? ( {isCreatingInitialCommit ? (
<> <>
@@ -1237,8 +1238,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</button> </button>
</div> </div>
) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? ( ) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400"> <div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<GitCommit className="w-12 h-12 mb-2 opacity-50" /> <GitCommit className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No changes detected</p> <p className="text-sm">No changes detected</p>
</div> </div>
) : ( ) : (
@@ -1257,11 +1258,11 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" /> <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div> </div>
) : recentCommits.length === 0 ? ( ) : recentCommits.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400"> <div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<History className="w-12 h-12 mb-2 opacity-50" /> <History className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No commits found</p> <p className="text-sm">No commits found</p>
</div> </div>
) : ( ) : (
@@ -1275,12 +1276,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* New Branch Modal */} {/* New Branch Modal */}
{showNewBranchModal && ( {showNewBranchModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowNewBranchModal(false)} /> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowNewBranchModal(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full"> <div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6"> <div className="p-6">
<h3 className="text-lg font-semibold mb-4">Create New Branch</h3> <h3 className="text-lg font-semibold text-foreground mb-4">Create New Branch</h3>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-foreground/80 mb-2">
Branch Name Branch Name
</label> </label>
<input <input
@@ -1293,11 +1294,11 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
} }
}} }}
placeholder="feature/new-feature" placeholder="feature/new-feature"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" 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 autoFocus
/> />
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-4"> <div className="text-xs text-muted-foreground mb-4">
This will create a new branch from the current branch ({currentBranch}) This will create a new branch from the current branch ({currentBranch})
</div> </div>
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
@@ -1306,14 +1307,14 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
setShowNewBranchModal(false); setShowNewBranchModal(false);
setNewBranchName(''); setNewBranchName('');
}} }}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md" className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={createBranch} onClick={createBranch}
disabled={!newBranchName.trim() || isCreatingBranch} disabled={!newBranchName.trim() || isCreatingBranch}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2" 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 ? ( {isCreatingBranch ? (
<> <>
@@ -1336,44 +1337,44 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Confirmation Modal */} {/* Confirmation Modal */}
{confirmAction && ( {confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setConfirmAction(null)} /> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmAction(null)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full"> <div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6"> <div className="p-6">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<div className={`p-2 rounded-full mr-3 ${ <div className={`p-2 rounded-full mr-3 ${
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900' (confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900/30' : 'bg-yellow-100 dark:bg-yellow-900/30'
}`}> }`}>
<AlertTriangle className={`w-5 h-5 ${ <AlertTriangle className={`w-5 h-5 ${
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400' (confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
}`} /> }`} />
</div> </div>
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold text-foreground">
{confirmAction.type === 'discard' ? 'Discard Changes' : {confirmAction.type === 'discard' ? 'Discard Changes' :
confirmAction.type === 'delete' ? 'Delete File' : confirmAction.type === 'delete' ? 'Delete File' :
confirmAction.type === 'commit' ? 'Confirm Commit' : confirmAction.type === 'commit' ? 'Confirm Commit' :
confirmAction.type === 'pull' ? 'Confirm Pull' : confirmAction.type === 'pull' ? 'Confirm Pull' :
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'} confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
</h3> </h3>
</div> </div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6"> <p className="text-sm text-muted-foreground mb-6">
{confirmAction.message} {confirmAction.message}
</p> </p>
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<button <button
onClick={() => setConfirmAction(null)} onClick={() => setConfirmAction(null)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md" className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={confirmAndExecute} onClick={confirmAndExecute}
className={`px-4 py-2 text-sm text-white rounded-md ${ className={`px-4 py-2 text-sm text-white rounded-lg transition-colors ${
(confirmAction.type === 'discard' || confirmAction.type === 'delete') (confirmAction.type === 'discard' || confirmAction.type === 'delete')
? 'bg-red-600 hover:bg-red-700' ? 'bg-red-600 hover:bg-red-700'
: confirmAction.type === 'commit' : confirmAction.type === 'commit'
? 'bg-blue-600 hover:bg-blue-700' ? 'bg-primary hover:bg-primary/90'
: confirmAction.type === 'pull' : confirmAction.type === 'pull'
? 'bg-green-600 hover:bg-green-700' ? 'bg-green-600 hover:bg-green-700'
: confirmAction.type === 'publish' : confirmAction.type === 'publish'

View File

@@ -1,74 +1,90 @@
import React from 'react'; import React from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, Globe, CheckSquare } from 'lucide-react'; import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTaskMaster } from '../contexts/TaskMasterContext';
function MobileNav({ activeTab, setActiveTab, isInputFocused }) { function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
const { tasksEnabled } = useTasksSettings(); const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const navItems = [ const navItems = [
{ {
id: 'chat', id: 'chat',
icon: MessageSquare, icon: MessageSquare,
label: 'Chat',
onClick: () => setActiveTab('chat') onClick: () => setActiveTab('chat')
}, },
{ {
id: 'shell', id: 'shell',
icon: Terminal, icon: Terminal,
label: 'Shell',
onClick: () => setActiveTab('shell') onClick: () => setActiveTab('shell')
}, },
{ {
id: 'files', id: 'files',
icon: Folder, icon: Folder,
label: 'Files',
onClick: () => setActiveTab('files') onClick: () => setActiveTab('files')
}, },
{ {
id: 'git', id: 'git',
icon: GitBranch, icon: GitBranch,
label: 'Git',
onClick: () => setActiveTab('git') onClick: () => setActiveTab('git')
}, },
// Conditionally add tasks tab if enabled ...(shouldShowTasksTab ? [{
...(tasksEnabled ? [{
id: 'tasks', id: 'tasks',
icon: CheckSquare, icon: ClipboardCheck,
label: 'Tasks',
onClick: () => setActiveTab('tasks') onClick: () => setActiveTab('tasks')
}] : []) }] : [])
]; ];
return ( return (
<div <div
className={`fixed bottom-0 left-0 right-0 bg-background border-t border-border z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${ className={`fixed bottom-0 left-0 right-0 z-50 px-3 pb-[max(8px,env(safe-area-inset-bottom))] transform transition-transform duration-300 ease-in-out ${
isInputFocused ? 'translate-y-full' : 'translate-y-0' isInputFocused ? 'translate-y-full' : 'translate-y-0'
}`} }`}
> >
<div className="flex items-center justify-around py-1"> <div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
{navItems.map((item) => { <div className="flex items-center justify-around px-1 py-1.5 gap-0.5">
const Icon = item.icon; {navItems.map((item) => {
const isActive = activeTab === item.id; const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button return (
key={item.id} <button
onClick={item.onClick} key={item.id}
onTouchStart={(e) => { onClick={item.onClick}
e.preventDefault(); onTouchStart={(e) => {
item.onClick(); e.preventDefault();
}} item.onClick();
className={`flex items-center justify-center p-2 rounded-lg min-h-[40px] min-w-[40px] relative touch-manipulation ${ }}
isActive className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
? 'text-blue-600 dark:text-blue-400' isActive
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' ? 'text-primary'
}`} : 'text-muted-foreground hover:text-foreground'
aria-label={item.id} }`}
> aria-label={item.label}
<Icon className="w-5 h-5" /> aria-current={isActive ? 'page' : undefined}
{isActive && ( >
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" /> {isActive && (
)} <div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" />
</button> )}
); <Icon
})} className={`relative z-10 transition-all duration-200 ${isActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`}
strokeWidth={isActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div> </div>
</div> </div>
); );
} }
export default MobileNav; export default MobileNav;

View File

@@ -74,7 +74,7 @@ export default function AppContent() {
return ( return (
<div className="fixed inset-0 flex bg-background"> <div className="fixed inset-0 flex bg-background">
{!isMobile ? ( {!isMobile ? (
<div className="h-full flex-shrink-0 border-r border-border bg-card"> <div className="h-full flex-shrink-0 border-r border-border/50">
<Sidebar {...sidebarSharedProps} /> <Sidebar {...sidebarSharedProps} />
</div> </div>
) : ( ) : (
@@ -83,7 +83,7 @@ export default function AppContent() {
}`} }`}
> >
<button <button
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out" className="fixed inset-0 bg-background/60 backdrop-blur-sm transition-opacity duration-150 ease-out"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setSidebarOpen(false); setSidebarOpen(false);
@@ -96,7 +96,7 @@ export default function AppContent() {
aria-label={t('versionUpdate.ariaLabels.closeSidebar')} aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/> />
<div <div
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full' className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border/40 transform transition-transform duration-150 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`} }`}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()}

View File

@@ -254,8 +254,8 @@ function ChatInterface({
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-center text-gray-500 dark:text-gray-400"> <div className="text-center text-muted-foreground">
<p> <p className="text-sm">
{t('projectSelection.startChatWithProvider', { {t('projectSelection.startChatWithProvider', {
provider: selectedProviderLabel, provider: selectedProviderLabel,
defaultValue: 'Select a project to start chatting with {{provider}}', defaultValue: 'Select a project to start chatting with {{provider}}',

View File

@@ -201,9 +201,9 @@ export default function ChatComposer({
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto"> {!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
{isDragActive && ( {isDragActive && (
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50"> <div className="absolute inset-0 bg-primary/15 border-2 border-dashed border-primary/50 rounded-2xl flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg"> <div className="bg-card rounded-xl p-4 shadow-lg border border-border/30">
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-8 h-8 text-primary mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -217,7 +217,7 @@ export default function ChatComposer({
)} )}
{attachedImages.length > 0 && ( {attachedImages.length > 0 && (
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg"> <div className="mb-2 p-2 bg-muted/40 rounded-xl">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => ( {attachedImages.map((file, index) => (
<ImageAttachment <ImageAttachment
@@ -233,14 +233,14 @@ export default function ChatComposer({
)} )}
{showFileDropdown && filteredFiles.length > 0 && ( {showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50 backdrop-blur-sm"> <div className="absolute bottom-full left-0 right-0 mb-2 bg-card/95 backdrop-blur-md border border-border/50 rounded-xl shadow-lg max-h-48 overflow-y-auto z-50">
{filteredFiles.map((file, index) => ( {filteredFiles.map((file, index) => (
<div <div
key={file.path} key={file.path}
className={`px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 touch-manipulation ${ className={`px-4 py-3 cursor-pointer border-b border-border/30 last:border-b-0 touch-manipulation ${
index === selectedFileIndex index === selectedFileIndex
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' ? 'bg-primary/8 text-primary'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : 'hover:bg-accent/50 text-foreground'
}`} }`}
onMouseDown={(event) => { onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
@@ -253,7 +253,7 @@ export default function ChatComposer({
}} }}
> >
<div className="font-medium text-sm">{file.name}</div> <div className="font-medium text-sm">{file.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">{file.path}</div> <div className="text-xs text-muted-foreground font-mono">{file.path}</div>
</div> </div>
))} ))}
</div> </div>
@@ -271,7 +271,7 @@ export default function ChatComposer({
<div <div
{...getRootProps()} {...getRootProps()}
className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${ className={`relative bg-card/80 backdrop-blur-sm rounded-2xl shadow-sm border border-border/50 focus-within:shadow-md focus-within:border-primary/30 focus-within:ring-1 focus-within:ring-primary/15 transition-all duration-200 overflow-hidden ${
isTextareaExpanded ? 'chat-input-expanded' : '' isTextareaExpanded ? 'chat-input-expanded' : ''
}`} }`}
> >
@@ -296,17 +296,17 @@ export default function ChatComposer({
onInput={onTextareaInput} onInput={onTextareaInput}
placeholder={placeholder} placeholder={placeholder}
disabled={isLoading} disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200" className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-foreground placeholder-muted-foreground/50 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
style={{ height: '50px' }} style={{ height: '50px' }}
/> />
<button <button
type="button" type="button"
onClick={openImagePicker} onClick={openImagePicker}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-accent/60 rounded-xl transition-colors"
title={t('input.attachImages')} title={t('input.attachImages')}
> >
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -331,15 +331,15 @@ export default function ChatComposer({
event.preventDefault(); event.preventDefault();
onSubmit(event); onSubmit(event);
}} }}
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-12 h-12 sm:w-12 sm:h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800" className="absolute right-2 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-11 sm:h-11 bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed rounded-xl flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background"
> >
<svg className="w-4 h-4 sm:w-5 sm:h-5 text-white transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 sm:w-[18px] sm:h-[18px] text-primary-foreground transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg> </svg>
</button> </button>
<div <div
className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block transition-opacity duration-200 ${ className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-muted-foreground/50 pointer-events-none hidden sm:block transition-opacity duration-200 ${
input.trim() ? 'opacity-0' : 'opacity-100' input.trim() ? 'opacity-0' : 'opacity-100'
}`} }`}
> >

View File

@@ -38,31 +38,31 @@ export default function ChatInputControls({
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
return ( return (
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-2 sm:gap-3 flex-wrap">
<button <button
type="button" type="button"
onClick={onModeSwitch} onClick={onModeSwitch}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${ className={`px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium border transition-all duration-200 ${
permissionMode === 'default' permissionMode === 'default'
? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600' ? 'bg-muted/50 text-muted-foreground border-border/60 hover:bg-muted'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-100 dark:hover:bg-green-900/30' ? 'bg-green-50 dark:bg-green-900/15 text-green-700 dark:text-green-300 border-green-300/60 dark:border-green-600/40 hover:bg-green-100 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions' : permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30' ? 'bg-orange-50 dark:bg-orange-900/15 text-orange-700 dark:text-orange-300 border-orange-300/60 dark:border-orange-600/40 hover:bg-orange-100 dark:hover:bg-orange-900/25'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30' : 'bg-primary/5 text-primary border-primary/20 hover:bg-primary/10'
}`} }`}
title={t('input.clickToChangeMode')} title={t('input.clickToChangeMode')}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<div <div
className={`w-2 h-2 rounded-full ${ className={`w-1.5 h-1.5 rounded-full ${
permissionMode === 'default' permissionMode === 'default'
? 'bg-gray-500' ? 'bg-muted-foreground'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
? 'bg-green-500' ? 'bg-green-500'
: permissionMode === 'bypassPermissions' : permissionMode === 'bypassPermissions'
? 'bg-orange-500' ? 'bg-orange-500'
: 'bg-blue-500' : 'bg-primary'
}`} }`}
/> />
<span> <span>
@@ -83,10 +83,10 @@ export default function ChatInputControls({
<button <button
type="button" type="button"
onClick={onToggleCommandMenu} onClick={onToggleCommandMenu}
className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800" className="relative w-7 h-7 sm:w-8 sm:h-8 text-muted-foreground hover:text-foreground rounded-lg flex items-center justify-center transition-colors hover:bg-accent/60"
title={t('input.showAllCommands')} title={t('input.showAllCommands')}
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -96,8 +96,7 @@ export default function ChatInputControls({
</svg> </svg>
{slashCommandsCount > 0 && ( {slashCommandsCount > 0 && (
<span <span
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center" className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[10px] font-bold rounded-full w-4 h-4 sm:w-5 sm:h-5 flex items-center justify-center"
style={{ fontSize: '10px' }}
> >
{slashCommandsCount} {slashCommandsCount}
</span> </span>
@@ -108,11 +107,11 @@ export default function ChatInputControls({
<button <button
type="button" type="button"
onClick={onClearInput} onClick={onClearInput}
className="w-8 h-8 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center transition-all duration-200 group shadow-sm" className="w-7 h-7 sm:w-8 sm:h-8 bg-card hover:bg-accent/60 border border-border/50 rounded-lg flex items-center justify-center transition-all duration-200 group shadow-sm"
title={t('input.clearInput', { defaultValue: 'Clear input' })} title={t('input.clearInput', { defaultValue: 'Clear input' })}
> >
<svg <svg
className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-gray-800 dark:group-hover:text-gray-100 transition-colors" className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground group-hover:text-foreground transition-colors"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -125,10 +124,10 @@ export default function ChatInputControls({
{isUserScrolledUp && hasMessages && ( {isUserScrolledUp && hasMessages && (
<button <button
onClick={onScrollToBottom} onClick={onScrollToBottom}
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800" className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })} title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg> </svg>
</button> </button>

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo'; import SessionProviderLogo from '../../../SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx'; import NextTaskBanner from '../../../NextTaskBanner.jsx';
@@ -23,6 +24,54 @@ interface ProviderSelectionEmptyStateProps {
setInput: React.Dispatch<React.SetStateAction<string>>; setInput: React.Dispatch<React.SetStateAction<string>>;
} }
type ProviderDef = {
id: SessionProvider;
name: string;
infoKey: string;
accent: string;
ring: string;
check: string;
};
const PROVIDERS: ProviderDef[] = [
{
id: 'claude',
name: 'Claude Code',
infoKey: 'providerSelection.providerInfo.anthropic',
accent: 'border-primary',
ring: 'ring-primary/15',
check: 'bg-primary text-primary-foreground',
},
{
id: 'cursor',
name: 'Cursor',
infoKey: 'providerSelection.providerInfo.cursorEditor',
accent: 'border-violet-500 dark:border-violet-400',
ring: 'ring-violet-500/15',
check: 'bg-violet-500 text-white',
},
{
id: 'codex',
name: 'Codex',
infoKey: 'providerSelection.providerInfo.openai',
accent: 'border-emerald-600 dark:border-emerald-400',
ring: 'ring-emerald-600/15',
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
},
];
function getModelConfig(p: SessionProvider) {
if (p === 'claude') return CLAUDE_MODELS;
if (p === 'codex') return CODEX_MODELS;
return CURSOR_MODELS;
}
function getModelValue(p: SessionProvider, c: string, cu: string, co: string) {
if (p === 'claude') return c;
if (p === 'codex') return co;
return cu;
}
export default function ProviderSelectionEmptyState({ export default function ProviderSelectionEmptyState({
selectedSession, selectedSession,
currentSessionId, currentSessionId,
@@ -41,185 +90,133 @@ export default function ProviderSelectionEmptyState({
setInput, setInput,
}: ProviderSelectionEmptyStateProps) { }: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
// Reuse one translated prompt so task-start behavior stays consistent across empty and session states.
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' }); const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
const selectProvider = (nextProvider: SessionProvider) => { const selectProvider = (next: SessionProvider) => {
setProvider(nextProvider); setProvider(next);
localStorage.setItem('selected-provider', nextProvider); localStorage.setItem('selected-provider', next);
setTimeout(() => textareaRef.current?.focus(), 100); setTimeout(() => textareaRef.current?.focus(), 100);
}; };
return ( const handleModelChange = (value: string) => {
<div className="flex items-center justify-center h-full"> if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
{!selectedSession && !currentSessionId && ( else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
<div className="text-center px-6 sm:px-4 py-8"> else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">{t('providerSelection.title')}</h2> };
<p className="text-gray-600 dark:text-gray-400 mb-8">{t('providerSelection.description')}</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8"> const modelConfig = getModelConfig(provider);
<button const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel);
onClick={() => selectProvider('claude')}
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
provider === 'claude'
? 'border-blue-500 shadow-lg ring-2 ring-blue-500/20'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<SessionProviderLogo provider="claude" className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Claude Code</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
</div>
</div>
{provider === 'claude' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
<button /* ── New session — provider picker ── */
onClick={() => selectProvider('cursor')} if (!selectedSession && !currentSessionId) {
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${ return (
provider === 'cursor' <div className="flex items-center justify-center h-full px-4">
? 'border-purple-500 shadow-lg ring-2 ring-purple-500/20' <div className="w-full max-w-md">
: 'border-gray-200 dark:border-gray-700 hover:border-purple-400' {/* Heading */}
}`} <div className="text-center mb-8">
> <h2 className="text-lg sm:text-xl font-semibold text-foreground tracking-tight">
<div className="flex flex-col items-center justify-center h-full gap-3"> {t('providerSelection.title')}
<SessionProviderLogo provider="cursor" className="w-10 h-10" /> </h2>
<div> <p className="text-[13px] text-muted-foreground mt-1">
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p> {t('providerSelection.description')}
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.cursorEditor')}</p> </p>
</div>
</div>
{provider === 'cursor' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-purple-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
<button
onClick={() => selectProvider('codex')}
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
provider === 'codex'
? 'border-gray-800 dark:border-gray-300 shadow-lg ring-2 ring-gray-800/20 dark:ring-gray-300/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<SessionProviderLogo provider="codex" className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.openai')}</p>
</div>
</div>
{provider === 'codex' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-gray-800 dark:bg-gray-300 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white dark:text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
</div> </div>
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}> {/* Provider cards — horizontal row, equal width */}
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t('providerSelection.selectModel')}</label> <div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
{provider === 'claude' ? ( {PROVIDERS.map((p) => {
<select const active = provider === p.id;
value={claudeModel} return (
onChange={(e) => { <button
const newModel = e.target.value; key={p.id}
setClaudeModel(newModel); onClick={() => selectProvider(p.id)}
localStorage.setItem('claude-model', newModel); className={`
}} relative flex flex-col items-center gap-2.5 pt-5 pb-4 px-2
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]" rounded-xl border-[1.5px] transition-all duration-150
> active:scale-[0.97]
{CLAUDE_MODELS.OPTIONS.map(({ value, label }) => ( ${active
<option key={value} value={value}> ? `${p.accent} ${p.ring} ring-2 bg-card shadow-sm`
{label} : 'border-border bg-card/60 hover:bg-card hover:border-border/80'
</option> }
))} `}
</select> >
) : provider === 'codex' ? ( <SessionProviderLogo
<select provider={p.id}
value={codexModel} className={`w-9 h-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
onChange={(e) => { />
const newModel = e.target.value; <div className="text-center">
setCodexModel(newModel); <p className="text-[13px] font-semibold text-foreground leading-none">{p.name}</p>
localStorage.setItem('codex-model', newModel); <p className="text-[10px] text-muted-foreground mt-1 leading-tight">{t(p.infoKey)}</p>
}} </div>
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500 min-w-[140px]" {/* Check badge */}
> {active && (
{CODEX_MODELS.OPTIONS.map(({ value, label }) => ( <div className={`absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
<option key={value} value={value}> <Check className="w-2.5 h-2.5" strokeWidth={3} />
{label} </div>
</option> )}
))} </button>
</select> );
) : ( })}
<select
value={cursorModel}
onChange={(e) => {
const newModel = e.target.value;
setCursorModel(newModel);
localStorage.setItem('cursor-model', newModel);
}}
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
disabled={provider !== 'cursor'}
>
{CURSOR_MODELS.OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
)}
</div> </div>
<p className="text-sm text-gray-500 dark:text-gray-400"> {/* Model picker — appears after provider is chosen */}
{provider === 'claude' <div className={`transition-all duration-200 ${provider ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1 pointer-events-none'}`}>
? t('providerSelection.readyPrompt.claude', { model: claudeModel }) <div className="flex items-center justify-center gap-2 mb-5">
: provider === 'cursor' <span className="text-xs text-muted-foreground">{t('providerSelection.selectModel')}</span>
? t('providerSelection.readyPrompt.cursor', { model: cursorModel }) <div className="relative">
: provider === 'codex' <select
? t('providerSelection.readyPrompt.codex', { model: codexModel }) value={currentModel}
: t('providerSelection.readyPrompt.default')} onChange={(e) => handleModelChange(e.target.value)}
</p> tabIndex={-1}
className="appearance-none pl-3 pr-7 py-1.5 text-xs font-medium bg-muted/50 border border-border/60 rounded-lg text-foreground cursor-pointer hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
</div>
<p className="text-center text-xs text-muted-foreground/70">
{provider === 'claude'
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor'
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex'
? t('providerSelection.readyPrompt.codex', { model: codexModel })
: t('providerSelection.readyPrompt.default')}
</p>
</div>
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && ( {provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-4 px-4 sm:px-0"> <div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} /> <NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div> </div>
)} )}
</div> </div>
)} </div>
{selectedSession && ( );
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4"> }
<p className="font-bold text-lg sm:text-xl mb-3">{t('session.continue.title')}</p>
<p className="text-sm sm:text-base leading-relaxed">{t('session.continue.description')}</p> /* ── Existing sessioncontinue prompt ── */
if (selectedSession) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center px-6 max-w-md">
<p className="text-lg font-semibold text-foreground mb-1.5">{t('session.continue.title')}</p>
<p className="text-sm text-muted-foreground leading-relaxed">{t('session.continue.description')}</p>
{tasksEnabled && isTaskMasterInstalled && ( {tasksEnabled && isTaskMasterInstalled && (
<div className="mt-4 px-4 sm:px-0"> <div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} /> <NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div> </div>
)} )}
</div> </div>
)} </div>
</div> );
); }
return null;
} }

View File

@@ -13,9 +13,9 @@ export default function MainContentHeader({
onMenuClick, onMenuClick,
}: MainContentHeaderProps) { }: MainContentHeaderProps) {
return ( return (
<div className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"> <div className="bg-background border-b border-border/60 px-3 py-1.5 sm:px-4 sm:py-2 pwa-header-safe flex-shrink-0">
<div className="flex items-center justify-between relative"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center space-x-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
{isMobile && <MobileMenuButton onMenuClick={onMenuClick} />} {isMobile && <MobileMenuButton onMenuClick={onMenuClick} />}
<MainContentTitle <MainContentTitle
activeTab={activeTab} activeTab={activeTab}

View File

@@ -1,3 +1,4 @@
import { Folder } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MobileMenuButton from './MobileMenuButton'; import MobileMenuButton from './MobileMenuButton';
import type { MainContentStateViewProps } from '../../types/types'; import type { MainContentStateViewProps } from '../../types/types';
@@ -10,17 +11,17 @@ export default function MainContentStateView({ mode, isMobile, onMenuClick }: Ma
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{isMobile && ( {isMobile && (
<div className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"> <div className="bg-background/80 backdrop-blur-sm border-b border-border/50 p-2 sm:p-3 pwa-header-safe flex-shrink-0">
<MobileMenuButton onMenuClick={onMenuClick} compact /> <MobileMenuButton onMenuClick={onMenuClick} compact />
</div> </div>
)} )}
{isLoading ? ( {isLoading ? (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400"> <div className="text-center text-muted-foreground">
<div className="w-12 h-12 mx-auto mb-4"> <div className="w-10 h-10 mx-auto mb-4">
<div <div
className="w-full h-full rounded-full border-4 border-gray-200 border-t-blue-500" className="w-full h-full rounded-full border-[3px] border-muted border-t-primary"
style={{ style={{
animation: 'spin 1s linear infinite', animation: 'spin 1s linear infinite',
WebkitAnimation: 'spin 1s linear infinite', WebkitAnimation: 'spin 1s linear infinite',
@@ -28,22 +29,20 @@ export default function MainContentStateView({ mode, isMobile, onMenuClick }: Ma
}} }}
/> />
</div> </div>
<h2 className="text-xl font-semibold mb-2">{t('mainContent.loading')}</h2> <h2 className="text-lg font-semibold text-foreground mb-1">{t('mainContent.loading')}</h2>
<p>{t('mainContent.settingUpWorkspace')}</p> <p className="text-sm">{t('mainContent.settingUpWorkspace')}</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400 max-w-md mx-auto px-6"> <div className="text-center max-w-md mx-auto px-6">
<div className="w-16 h-16 mx-auto mb-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center"> <div className="w-14 h-14 mx-auto mb-5 bg-muted/50 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Folder className="w-7 h-7 text-muted-foreground" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div> </div>
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">{t('mainContent.chooseProject')}</h2> <h2 className="text-xl font-semibold mb-2 text-foreground">{t('mainContent.chooseProject')}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">{t('mainContent.selectProjectDescription')}</p> <p className="text-sm text-muted-foreground mb-5 leading-relaxed">{t('mainContent.selectProjectDescription')}</p>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800"> <div className="bg-primary/5 rounded-xl p-3.5 border border-primary/10">
<p className="text-sm text-blue-700 dark:text-blue-300"> <p className="text-sm text-primary">
<strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')} <strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
</p> </p>
</div> </div>

View File

@@ -1,3 +1,4 @@
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
import Tooltip from '../../../Tooltip'; import Tooltip from '../../../Tooltip';
import type { AppTab } from '../../../../types/app'; import type { AppTab } from '../../../../types/app';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
@@ -12,50 +13,22 @@ type MainContentTabSwitcherProps = {
type TabDefinition = { type TabDefinition = {
id: AppTab; id: AppTab;
labelKey: string; labelKey: string;
iconPath: string; icon: LucideIcon;
}; };
const BASE_TABS: TabDefinition[] = [ const BASE_TABS: TabDefinition[] = [
{ { id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
id: 'chat', { id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
labelKey: 'tabs.chat', { id: 'files', labelKey: 'tabs.files', icon: Folder },
iconPath: { id: 'git', labelKey: 'tabs.git', icon: GitBranch },
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
},
{
id: 'shell',
labelKey: 'tabs.shell',
iconPath: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z',
},
{
id: 'files',
labelKey: 'tabs.files',
iconPath: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z',
},
{
id: 'git',
labelKey: 'tabs.git',
iconPath: 'M13 10V3L4 14h7v7l9-11h-7z',
},
]; ];
const TASKS_TAB: TabDefinition = { const TASKS_TAB: TabDefinition = {
id: 'tasks', id: 'tasks',
labelKey: 'tabs.tasks', labelKey: 'tabs.tasks',
iconPath: icon: ClipboardCheck,
'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4',
}; };
function getButtonClasses(tabId: AppTab, activeTab: AppTab) {
const base = 'relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200';
if (tabId === activeTab) {
return `${base} bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm`;
}
return `${base} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700`;
}
export default function MainContentTabSwitcher({ export default function MainContentTabSwitcher({
activeTab, activeTab,
setActiveTab, setActiveTab,
@@ -66,19 +39,27 @@ export default function MainContentTabSwitcher({
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS; const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
return ( return (
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1"> <div className="inline-flex items-center bg-muted/60 rounded-lg p-[3px] gap-[2px]">
{tabs.map((tab) => ( {tabs.map((tab) => {
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom"> const Icon = tab.icon;
<button onClick={() => setActiveTab(tab.id)} className={getButtonClasses(tab.id, activeTab)}> const isActive = tab.id === activeTab;
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> return (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={tab.iconPath} /> <Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
</svg> <button
<span className="hidden md:hidden lg:inline">{t(tab.labelKey)}</span> onClick={() => setActiveTab(tab.id)}
</span> className={`relative flex items-center gap-1.5 px-2.5 py-[5px] text-xs font-medium rounded-md transition-all duration-150 ${
</button> isActive
</Tooltip> ? 'bg-background text-foreground shadow-sm'
))} : 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-3.5 h-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
</button>
</Tooltip>
);
})}
</div> </div>
); );
} }

View File

@@ -55,22 +55,22 @@ export default function MainContentTitle({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{activeTab === 'chat' && selectedSession ? ( {activeTab === 'chat' && selectedSession ? (
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white whitespace-nowrap overflow-x-auto scrollbar-hide"> <h2 className="text-sm font-semibold text-foreground whitespace-nowrap overflow-x-auto scrollbar-hide leading-tight">
{getSessionTitle(selectedSession)} {getSessionTitle(selectedSession)}
</h2> </h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedProject.displayName}</div> <div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div> </div>
) : showChatNewSession ? ( ) : showChatNewSession ? (
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">{t('mainContent.newSession')}</h2> <h2 className="text-sm font-semibold text-foreground leading-tight">{t('mainContent.newSession')}</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedProject.displayName}</div> <div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div> </div>
) : ( ) : (
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white"> <h2 className="text-sm font-semibold text-foreground leading-tight">
{getTabTitle(activeTab, shouldShowTasksTab, t)} {getTabTitle(activeTab, shouldShowTasksTab, t)}
</h2> </h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedProject.displayName}</div> <div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -5,8 +5,8 @@ export default function MobileMenuButton({ onMenuClick, compact = false }: Mobil
const { handleMobileMenuClick, handleMobileMenuTouchEnd } = useMobileMenuHandlers(onMenuClick); const { handleMobileMenuClick, handleMobileMenuTouchEnd } = useMobileMenuHandlers(onMenuClick);
const buttonClasses = compact const buttonClasses = compact
? 'p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button' ? 'p-1.5 text-muted-foreground hover:text-foreground rounded-lg hover:bg-accent/60 pwa-menu-button'
: 'p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0'; : 'p-1.5 text-muted-foreground hover:text-foreground rounded-lg hover:bg-accent/60 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0';
return ( return (
<button <button

View File

@@ -1,4 +1,4 @@
import { Settings, Sparkles } from 'lucide-react'; import { Settings, Sparkles, PanelLeftOpen } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
type SidebarCollapsedProps = { type SidebarCollapsedProps = {
@@ -17,41 +17,39 @@ export default function SidebarCollapsed({
t, t,
}: SidebarCollapsedProps) { }: SidebarCollapsedProps) {
return ( return (
<div className="h-full flex flex-col items-center py-4 gap-4 bg-card"> <div className="h-full flex flex-col items-center py-3 gap-1 bg-background/80 backdrop-blur-sm w-12">
{/* Expand button with brand logo */}
<button <button
onClick={onExpand} onClick={onExpand}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group" className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors group"
aria-label={t('common:versionUpdate.ariaLabels.showSidebar')} aria-label={t('common:versionUpdate.ariaLabels.showSidebar')}
title={t('common:versionUpdate.ariaLabels.showSidebar')} title={t('common:versionUpdate.ariaLabels.showSidebar')}
> >
<svg <PanelLeftOpen className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button> </button>
<div className="nav-divider w-6 my-1" />
{/* Settings */}
<button <button
onClick={onShowSettings} onClick={onShowSettings}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200" className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors group"
aria-label={t('actions.settings')} aria-label={t('actions.settings')}
title={t('actions.settings')} title={t('actions.settings')}
> >
<Settings className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" /> <Settings className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
</button> </button>
{/* Update indicator */}
{updateAvailable && ( {updateAvailable && (
<button <button
onClick={onShowVersionModal} onClick={onShowVersionModal}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200" className="relative w-8 h-8 rounded-lg flex items-center justify-center hover:bg-accent/80 transition-colors"
aria-label={t('common:versionUpdate.ariaLabels.updateAvailable')} aria-label={t('common:versionUpdate.ariaLabels.updateAvailable')}
title={t('common:versionUpdate.ariaLabels.updateAvailable')} title={t('common:versionUpdate.ariaLabels.updateAvailable')}
> >
<Sparkles className="w-5 h-5 text-blue-500" /> <Sparkles className="w-4 h-4 text-blue-500" />
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" /> <span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
</button> </button>
)} )}
</div> </div>

View File

@@ -49,7 +49,7 @@ export default function SidebarContent({
}: SidebarContentProps) { }: SidebarContentProps) {
return ( return (
<div <div
className="h-full flex flex-col bg-card md:select-none md:w-80" className="h-full flex flex-col bg-background/80 backdrop-blur-sm md:select-none md:w-72"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}} style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
> >
<SidebarHeader <SidebarHeader
@@ -67,7 +67,7 @@ export default function SidebarContent({
t={t} t={t}
/> />
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain"> <ScrollArea className="flex-1 md:px-1.5 md:py-2 overflow-y-auto overscroll-contain">
<SidebarProjectList {...projectListProps} /> <SidebarProjectList {...projectListProps} />
</ScrollArea> </ScrollArea>

View File

@@ -1,7 +1,6 @@
import { Settings } from 'lucide-react'; import { Settings, ArrowUpCircle } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ReleaseInfo } from '../../../../types/sharedTypes';
import { Button } from '../../../ui/button';
type SidebarFooterProps = { type SidebarFooterProps = {
updateAvailable: boolean; updateAvailable: boolean;
@@ -21,74 +20,81 @@ export default function SidebarFooter({
t, t,
}: SidebarFooterProps) { }: SidebarFooterProps) {
return ( return (
<> <div className="flex-shrink-0">
{/* Update banner */}
{updateAvailable && ( {updateAvailable && (
<div className="md:p-2 border-t border-border/50 flex-shrink-0"> <>
<div className="hidden md:block"> <div className="nav-divider" />
<Button {/* Desktop update */}
variant="ghost" <div className="hidden md:block px-2 py-1.5">
className="w-full justify-start gap-3 p-3 h-auto font-normal text-left hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors duration-200 border border-blue-200 dark:border-blue-700 rounded-lg mb-2" <button
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-left hover:bg-blue-50/80 dark:hover:bg-blue-900/15 transition-colors group"
onClick={onShowVersionModal} onClick={onShowVersionModal}
> >
<div className="relative"> <div className="relative flex-shrink-0">
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <ArrowUpCircle className="w-4 h-4 text-blue-500 dark:text-blue-400" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" /> <span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
</svg>
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300"> <span className="text-xs font-medium text-blue-600 dark:text-blue-300 truncate block">
{releaseInfo?.title || `Version ${latestVersion}`} {releaseInfo?.title || `v${latestVersion}`}
</div> </span>
<div className="text-xs text-blue-600 dark:text-blue-400">{t('version.updateAvailable')}</div> <span className="text-[10px] text-blue-500/70 dark:text-blue-400/60">
</div> {t('version.updateAvailable')}
</Button> </span>
</div>
<div className="md:hidden p-3 pb-2">
<button
className="w-full h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl flex items-center justify-start gap-3 px-4 active:scale-[0.98] transition-all duration-150"
onClick={onShowVersionModal}
>
<div className="relative">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">{t('version.updateAvailable')}</div>
</div> </div>
</button> </button>
</div> </div>
</div>
{/* Mobile update */}
<div className="md:hidden px-3 py-2">
<button
className="w-full h-11 bg-blue-50/80 dark:bg-blue-900/15 border border-blue-200/60 dark:border-blue-700/40 rounded-xl flex items-center gap-3 px-3.5 active:scale-[0.98] transition-all"
onClick={onShowVersionModal}
>
<div className="relative flex-shrink-0">
<ArrowUpCircle className="w-4.5 h-4.5 text-blue-500 dark:text-blue-400" />
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1 text-left">
<span className="text-sm font-medium text-blue-600 dark:text-blue-300 truncate block">
{releaseInfo?.title || `v${latestVersion}`}
</span>
<span className="text-xs text-blue-500/70 dark:text-blue-400/60">
{t('version.updateAvailable')}
</span>
</div>
</button>
</div>
</>
)} )}
<div className="md:p-2 md:border-t md:border-border flex-shrink-0"> {/* Settings */}
<div className="md:hidden p-4 pb-20 border-t border-border/50"> <div className="nav-divider" />
<button
className="w-full h-14 bg-muted/50 hover:bg-muted/70 rounded-2xl flex items-center justify-start gap-4 px-4 active:scale-[0.98] transition-all duration-150"
onClick={onShowSettings}
>
<div className="w-10 h-10 rounded-2xl bg-background/80 flex items-center justify-center">
<Settings className="w-5 h-5 text-muted-foreground" />
</div>
<span className="text-lg font-medium text-foreground">{t('actions.settings')}</span>
</button>
</div>
<Button {/* Desktop settings */}
variant="ghost" <div className="hidden md:block px-2 py-1.5">
className="hidden md:flex w-full justify-start gap-2 p-2 h-auto font-normal text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200" <button
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
onClick={onShowSettings} onClick={onShowSettings}
> >
<Settings className="w-3 h-3" /> <Settings className="w-3.5 h-3.5" />
<span className="text-xs">{t('actions.settings')}</span> <span className="text-xs">{t('actions.settings')}</span>
</Button> </button>
</div> </div>
</>
{/* Mobile settings */}
<div className="md:hidden p-3 pb-20">
<button
className="w-full h-12 bg-muted/40 hover:bg-muted/60 rounded-xl flex items-center gap-3.5 px-4 active:scale-[0.98] transition-all"
onClick={onShowSettings}
>
<div className="w-8 h-8 rounded-xl bg-background/80 flex items-center justify-center">
<Settings className="w-4.5 h-4.5 text-muted-foreground" />
</div>
<span className="text-base font-medium text-foreground">{t('actions.settings')}</span>
</button>
</div>
</div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { FolderPlus, MessageSquare, RefreshCw, Search, X } from 'lucide-react'; import { FolderPlus, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Button } from '../../../ui/button'; import { Button } from '../../../ui/button';
import { Input } from '../../../ui/input'; import { Input } from '../../../ui/input';
@@ -33,155 +33,159 @@ export default function SidebarHeader({
onCollapseSidebar, onCollapseSidebar,
t, t,
}: SidebarHeaderProps) { }: SidebarHeaderProps) {
const LogoBlock = () => (
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-7 h-7 bg-primary/90 rounded-lg flex items-center justify-center shadow-sm flex-shrink-0">
<svg className="w-3.5 h-3.5 text-primary-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<h1 className="text-sm font-semibold text-foreground tracking-tight truncate">{t('app.title')}</h1>
</div>
);
return ( return (
<> <div className="flex-shrink-0">
{/* Desktop header */}
<div <div
className="md:p-4 md:border-b md:border-border" className="hidden md:block px-3 pt-3 pb-2"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}} style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
> >
<div className="hidden md:flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
{IS_PLATFORM ? ( {IS_PLATFORM ? (
<a <a
href="https://cloudcli.ai/dashboard" href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 hover:opacity-80 transition-opacity group" className="flex items-center gap-2.5 min-w-0 hover:opacity-80 transition-opacity"
title={t('tooltips.viewEnvironments')} title={t('tooltips.viewEnvironments')}
> >
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm group-hover:shadow-md transition-shadow"> <LogoBlock />
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-bold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
</div>
</a> </a>
) : ( ) : (
<div className="flex items-center gap-3"> <LogoBlock />
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-bold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
</div>
</div>
)} )}
<Button <div className="flex items-center gap-0.5 flex-shrink-0">
variant="ghost"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
onClick={onCollapseSidebar}
title={t('tooltips.hideSidebar')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Button>
</div>
<div
className="md:hidden p-3 border-b border-border"
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
>
<div className="flex items-center justify-between">
{IS_PLATFORM ? (
<a
href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 active:opacity-70 transition-opacity"
title={t('tooltips.viewEnvironments')}
>
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('projects.title')}</p>
</div>
</a>
) : (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('projects.title')}</p>
</div>
</div>
)}
<div className="flex gap-2">
<button
className="w-8 h-8 rounded-md bg-background border border-border flex items-center justify-center active:scale-95 transition-all duration-150"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 text-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
className="w-8 h-8 rounded-md bg-primary text-primary-foreground flex items-center justify-center active:scale-95 transition-all duration-150"
onClick={onCreateProject}
>
<FolderPlus className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
{!isLoading && !isMobile && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="flex gap-2">
<Button <Button
variant="default" variant="ghost"
size="sm" size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground hover:bg-accent/80 rounded-lg"
onClick={onCreateProject}
title={t('tooltips.createProject')}
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
{t('projects.newProject')}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={onRefresh} onClick={onRefresh}
disabled={isRefreshing} disabled={isRefreshing}
title={t('tooltips.refresh')} title={t('tooltips.refresh')}
> >
<RefreshCw <RefreshCw
className={`w-3.5 h-3.5 ${ className={`w-3.5 h-3.5 ${
isRefreshing ? 'animate-spin' : 'group-hover:rotate-180 transition-transform duration-300' isRefreshing ? 'animate-spin' : ''
}`} }`}
/> />
</Button> </Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground hover:bg-accent/80 rounded-lg"
onClick={onCreateProject}
title={t('tooltips.createProject')}
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground hover:bg-accent/80 rounded-lg"
onClick={onCollapseSidebar}
title={t('tooltips.hideSidebar')}
>
<PanelLeftClose className="w-3.5 h-3.5" />
</Button>
</div> </div>
</div> </div>
)}
{projectsCount > 0 && !isLoading && ( {/* Search bar */}
<div className="px-3 md:px-4 py-2 border-b border-border"> {projectsCount > 0 && !isLoading && (
<div className="relative"> <div className="relative mt-2.5">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
<Input <Input
type="text" type="text"
placeholder={t('projects.searchPlaceholder')} placeholder={t('projects.searchPlaceholder')}
value={searchFilter} value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)} onChange={(event) => onSearchFilterChange(event.target.value)}
className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20" className="nav-search-input pl-9 pr-8 h-9 text-xs rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
/> />
{searchFilter && ( {searchFilter && (
<button <button
onClick={onClearSearchFilter} onClick={onClearSearchFilter}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 hover:bg-accent rounded" className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded-md"
> >
<X className="w-3 h-3 text-muted-foreground" /> <X className="w-3 h-3 text-muted-foreground" />
</button> </button>
)} )}
</div> </div>
)}
</div>
{/* Desktop divider */}
<div className="hidden md:block nav-divider" />
{/* Mobile header */}
<div
className="md:hidden p-3 pb-2"
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
>
<div className="flex items-center justify-between">
{IS_PLATFORM ? (
<a
href="https://cloudcli.ai/dashboard"
className="flex items-center gap-2.5 active:opacity-70 transition-opacity min-w-0"
title={t('tooltips.viewEnvironments')}
>
<LogoBlock />
</a>
) : (
<LogoBlock />
)}
<div className="flex gap-1.5 flex-shrink-0">
<button
className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center active:scale-95 transition-all"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 text-muted-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
className="w-8 h-8 rounded-lg bg-primary/90 text-primary-foreground flex items-center justify-center active:scale-95 transition-all"
onClick={onCreateProject}
>
<FolderPlus className="w-4 h-4" />
</button>
</div>
</div> </div>
)}
</> {/* Mobile search */}
{projectsCount > 0 && !isLoading && (
<div className="relative mt-2.5">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50 pointer-events-none" />
<Input
type="text"
placeholder={t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input pl-10 pr-9 h-10 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
/>
{searchFilter && (
<button
onClick={onClearSearchFilter}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded-md"
>
<X className="w-3.5 h-3.5 text-muted-foreground" />
</button>
)}
</div>
)}
</div>
{/* Mobile divider */}
<div className="md:hidden nav-divider" />
</div>
); );
} }

View File

@@ -84,12 +84,16 @@ export default function SidebarModals({
document.body, document.body,
)} )}
<TypedSettings {showSettings &&
isOpen={showSettings} ReactDOM.createPortal(
onClose={onCloseSettings} <TypedSettings
projects={settingsProjects} isOpen={showSettings}
initialTab={settingsInitialTab} onClose={onCloseSettings}
/> projects={settingsProjects}
initialTab={settingsInitialTab}
/>,
document.body,
)}
{deleteConfirmation && {deleteConfirmation &&
ReactDOM.createPortal( ReactDOM.createPortal(

View File

@@ -43,7 +43,19 @@
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%; --ring: 221.2 83.2% 53.3%;
--radius: 0.5rem; --radius: 0.5rem;
/* Nav design tokens */
--nav-glass-bg: 0 0% 100% / 0.7;
--nav-glass-blur: 20px;
--nav-glass-saturate: 1.8;
--nav-tab-glow: 221.2 83.2% 53.3% / 0.18;
--nav-tab-ring: 221.2 83.2% 53.3% / 0.10;
--nav-float-shadow: 0 0% 0% / 0.06;
--nav-float-ring: 214.3 31.8% 91.4% / 0.5;
--nav-divider-color: 214.3 31.8% 91.4% / 0.5;
--nav-input-bg: 210 40% 96.1% / 0.5;
--nav-input-focus-ring: 221.2 83.2% 53.3% / 0.22;
/* Safe area CSS variables */ /* Safe area CSS variables */
--safe-area-inset-top: env(safe-area-inset-top); --safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-right: env(safe-area-inset-right); --safe-area-inset-right: env(safe-area-inset-right);
@@ -51,9 +63,10 @@
--safe-area-inset-left: env(safe-area-inset-left); --safe-area-inset-left: env(safe-area-inset-left);
/* Mobile navigation dimensions - Single source of truth */ /* Mobile navigation dimensions - Single source of truth */
--mobile-nav-height: 60px; /* Floating nav: ~52px bar + 8px bottom margin + 12px px-3 top spacing */
--mobile-nav-padding: 12px; --mobile-nav-height: 52px;
--mobile-nav-total: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px)); --mobile-nav-padding: 20px;
--mobile-nav-total: calc(var(--mobile-nav-height) + var(--mobile-nav-padding) + env(safe-area-inset-bottom, 0px));
/* Header safe area dimensions */ /* Header safe area dimensions */
--header-safe-area-top: env(safe-area-inset-top, 0px); --header-safe-area-top: env(safe-area-inset-top, 0px);
@@ -91,6 +104,18 @@
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 220 13% 46%; --input: 220 13% 46%;
--ring: 217.2 91.2% 59.8%; --ring: 217.2 91.2% 59.8%;
/* Nav design tokens — dark overrides */
--nav-glass-bg: 217.2 91.2% 8% / 0.55;
--nav-glass-blur: 24px;
--nav-glass-saturate: 1.6;
--nav-tab-glow: 217.2 91.2% 59.8% / 0.25;
--nav-tab-ring: 217.2 91.2% 59.8% / 0.15;
--nav-float-shadow: 0 0% 0% / 0.35;
--nav-float-ring: 217.2 32.6% 17.5% / 0.3;
--nav-divider-color: 217.2 32.6% 17.5% / 0.5;
--nav-input-bg: 217.2 32.6% 17.5% / 0.5;
--nav-input-focus-ring: 217.2 91.2% 59.8% / 0.25;
} }
} }
@@ -240,6 +265,42 @@
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1), transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 300ms ease-in-out; opacity 300ms ease-in-out;
} }
/* Nav glass surface — uses theme tokens */
.nav-glass {
background: hsl(var(--nav-glass-bg));
backdrop-filter: blur(var(--nav-glass-blur)) saturate(var(--nav-glass-saturate));
-webkit-backdrop-filter: blur(var(--nav-glass-blur)) saturate(var(--nav-glass-saturate));
}
/* Nav tab active pill glow — uses theme tokens */
.nav-tab-active {
box-shadow: 0 1px 8px hsl(var(--nav-tab-glow)),
0 0 0 1px hsl(var(--nav-tab-ring));
}
/* Floating mobile nav bar — uses theme tokens */
.mobile-nav-float {
box-shadow: 0 -1px 20px hsl(var(--nav-float-shadow)),
0 0 0 1px hsl(var(--nav-float-ring));
}
/* Subtle sidebar divider — uses theme tokens */
.nav-divider {
height: 1px;
background: linear-gradient(90deg, transparent, hsl(var(--nav-divider-color)) 20%, hsl(var(--nav-divider-color)) 80%, transparent);
}
/* Nav search input surface — uses theme tokens */
.nav-search-input {
background: hsl(var(--nav-input-bg));
border: none;
}
.nav-search-input:focus-within {
background: hsl(var(--background));
box-shadow: 0 0 0 2px hsl(var(--nav-input-focus-ring));
}
/* Modal and dropdown transitions */ /* Modal and dropdown transitions */
.modal-transition { .modal-transition {