mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-09 09:09:37 +00:00
Compare commits
56 Commits
e5709d71e0
...
7d0fd141ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0fd141ff | ||
|
|
e5a05d9865 | ||
|
|
2d6c3b5755 | ||
|
|
2a5d27ffc0 | ||
|
|
3c9a4cab82 | ||
|
|
ce9ab0cd16 | ||
|
|
66fad9a6a2 | ||
|
|
0fcf906ff0 | ||
|
|
7e1f2940d3 | ||
|
|
533d589132 | ||
|
|
d1f310161f | ||
|
|
b853e8cda1 | ||
|
|
1610de1f22 | ||
|
|
3f743d8210 | ||
|
|
cbb18fb010 | ||
|
|
d8d754274a | ||
|
|
eb12aef641 | ||
|
|
5e574dbdec | ||
|
|
af9e9eec02 | ||
|
|
8c3ee770c3 | ||
|
|
58108c083c | ||
|
|
af0ad6b4b6 | ||
|
|
36d9f47e29 | ||
|
|
1f25f1e79b | ||
|
|
d7ed1de1cb | ||
|
|
9be54233d0 | ||
|
|
c8bcad71e7 | ||
|
|
a3f504aed2 | ||
|
|
f766ac1517 | ||
|
|
680d8f6fb1 | ||
|
|
1820f3bfa8 | ||
|
|
376e055443 | ||
|
|
f4becdc218 | ||
|
|
70b421e5a8 | ||
|
|
d74a99ef24 | ||
|
|
771f7e4d7b | ||
|
|
cab13a1534 | ||
|
|
1eba594418 | ||
|
|
ed5374a1bd | ||
|
|
573a04d2e5 | ||
|
|
133af82935 | ||
|
|
79981693f3 | ||
|
|
fb1117a999 | ||
|
|
40b8737732 | ||
|
|
15b95c4d08 | ||
|
|
3ff1db0331 | ||
|
|
dab0068d8f | ||
|
|
3daf21c3d1 | ||
|
|
34583a7c7b | ||
|
|
b3498932e1 | ||
|
|
f52ca8e702 | ||
|
|
11b2ff588a | ||
|
|
06bb5feb09 | ||
|
|
4ca78ba69a | ||
|
|
975e4b04a6 | ||
|
|
d82a004224 |
@@ -9,4 +9,7 @@
|
|||||||
#API server
|
#API server
|
||||||
PORT=3001
|
PORT=3001
|
||||||
#Frontend port
|
#Frontend port
|
||||||
VITE_PORT=5173
|
VITE_PORT=5173
|
||||||
|
|
||||||
|
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||||
|
# CLAUDE_CLI_PATH=claude
|
||||||
|
|||||||
21
.release-it.json
Normal file
21
.release-it.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"commitMessage": "Release ${version}",
|
||||||
|
"tagName": "v${version}",
|
||||||
|
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"publish": true
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"releaseName": "Claude Code UI v${version}",
|
||||||
|
"releaseNotes": {
|
||||||
|
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
|
||||||
|
"excludeMatches": ["viper151"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"before:init": ["npm run build"]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
README.md
12
README.md
@@ -59,7 +59,17 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
|||||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured
|
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured
|
||||||
|
|
||||||
### Installation
|
### One-click Operation (Recommended)
|
||||||
|
|
||||||
|
No installation required, direct operation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @siteboon/claude-code-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Your default browser will automatically open the Claude Code UI interface.
|
||||||
|
|
||||||
|
### Local Development Installation
|
||||||
|
|
||||||
1. **Clone the repository:**
|
1. **Clone the repository:**
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Claude Code UI</title>
|
<title>Claude Code UI</title>
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<!-- iOS Safari PWA Meta Tags -->
|
<!-- iOS Safari PWA Meta Tags -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Claude UI" />
|
<meta name="apple-mobile-web-app-title" content="Claude UI" />
|
||||||
|
|
||||||
<!-- iOS Safari Icons -->
|
<!-- iOS Safari Icons -->
|
||||||
|
|||||||
2285
package-lock.json
generated
2285
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,23 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.8.0",
|
"version": "1.8.10",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
|
"bin": {
|
||||||
|
"claude-code-ui": "server/index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"server/",
|
||||||
|
"dist/",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"homepage": "https://claudecodeui.siteboon.ai",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/siteboon/claudecodeui/issues"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
||||||
"server": "node server/index.js",
|
"server": "node server/index.js",
|
||||||
"client": "vite --host",
|
"client": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "npm run build && npm run server"
|
"start": "npm run build && npm run server",
|
||||||
|
"release": "release-it"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude coode",
|
||||||
"ai",
|
"ai",
|
||||||
"code",
|
"anthropic",
|
||||||
"ui",
|
"ui",
|
||||||
"assistant"
|
"mobile"
|
||||||
],
|
],
|
||||||
"author": "Claude Code UI Contributors",
|
"author": "Claude Code UI Contributors",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -63,10 +80,12 @@
|
|||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"auto-changelog": "^2.5.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"node-gyp": "^10.0.0",
|
"node-gyp": "^10.0.0",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
"release-it": "^19.0.5",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
|
|||||||
@@ -25,15 +25,6 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
// Build Claude CLI command - start with print/resume flags first
|
// Build Claude CLI command - start with print/resume flags first
|
||||||
const args = [];
|
const args = [];
|
||||||
|
|
||||||
// Add print flag with command if we have a command
|
|
||||||
if (command && command.trim()) {
|
|
||||||
|
|
||||||
// Separate arguments for better cross-platform compatibility
|
|
||||||
// This prevents issues with spaces and quotes on Windows
|
|
||||||
args.push('--print');
|
|
||||||
args.push(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||||
const workingDir = cwd || process.cwd();
|
const workingDir = cwd || process.cwd();
|
||||||
|
|
||||||
@@ -225,6 +216,17 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
console.log('📝 Skip permissions disabled due to plan mode');
|
console.log('📝 Skip permissions disabled due to plan mode');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add print flag with command if we have a command
|
||||||
|
if (command && command.trim()) {
|
||||||
|
|
||||||
|
// Separate arguments for better cross-platform compatibility
|
||||||
|
// This prevents issues with spaces and quotes on Windows
|
||||||
|
args.push('--print');
|
||||||
|
// Use `--` so user input is always treated as text, not options
|
||||||
|
args.push('--');
|
||||||
|
args.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
||||||
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||||
@@ -235,7 +237,11 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
|
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
|
||||||
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
|
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
|
||||||
|
|
||||||
const claudeProcess = spawnFunction('claude', args, {
|
// Use Claude CLI from environment variable or default to 'claude'
|
||||||
|
const claudePath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||||
|
console.log('🔍 Using Claude CLI path:', claudePath);
|
||||||
|
|
||||||
|
const claudeProcess = spawnFunction(claudePath, args, {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env } // Inherit all environment variables
|
env: { ...process.env } // Inherit all environment variables
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
// Load environment variables from .env file
|
// Load environment variables from .env file
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -301,6 +302,66 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
||||||
|
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { path: dirPath } = req.query;
|
||||||
|
|
||||||
|
// Default to home directory if no path provided
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
|
||||||
|
|
||||||
|
// Resolve and normalize the path
|
||||||
|
targetPath = path.resolve(targetPath);
|
||||||
|
|
||||||
|
// Security check - ensure path is accessible
|
||||||
|
try {
|
||||||
|
await fs.promises.access(targetPath);
|
||||||
|
const stats = await fs.promises.stat(targetPath);
|
||||||
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return res.status(400).json({ error: 'Path is not a directory' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(404).json({ error: 'Directory not accessible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing getFileTree function with shallow depth (only direct children)
|
||||||
|
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||||
|
|
||||||
|
// Filter only directories and format for suggestions
|
||||||
|
const directories = fileTree
|
||||||
|
.filter(item => item.type === 'directory')
|
||||||
|
.map(item => ({
|
||||||
|
path: item.path,
|
||||||
|
name: item.name,
|
||||||
|
type: 'directory'
|
||||||
|
}))
|
||||||
|
.slice(0, 20); // Limit results
|
||||||
|
|
||||||
|
// Add common directories if browsing home directory
|
||||||
|
const suggestions = [];
|
||||||
|
if (targetPath === homeDir) {
|
||||||
|
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
||||||
|
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
||||||
|
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
||||||
|
|
||||||
|
suggestions.push(...existingCommon, ...otherDirs);
|
||||||
|
} else {
|
||||||
|
suggestions.push(...directories);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
path: targetPath,
|
||||||
|
suggestions: suggestions
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error browsing filesystem:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to browse filesystem' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Read file content endpoint
|
// Read file content endpoint
|
||||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -446,7 +507,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
|||||||
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await getFileTree(actualPath, 3, 0, true);
|
const files = await getFileTree(actualPath, 10, 0, true);
|
||||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
||||||
res.json(files);
|
res.json(files);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|||||||
return { sessions: [], hasMore: false, total: 0 };
|
return { sessions: [], hasMore: false, total: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// For performance, get file stats to sort by modification time
|
// Sort files by modification time (newest first)
|
||||||
const filesWithStats = await Promise.all(
|
const filesWithStats = await Promise.all(
|
||||||
jsonlFiles.map(async (file) => {
|
jsonlFiles.map(async (file) => {
|
||||||
const filePath = path.join(projectDir, file);
|
const filePath = path.join(projectDir, file);
|
||||||
@@ -540,40 +540,97 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|||||||
return { file, mtime: stats.mtime };
|
return { file, mtime: stats.mtime };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort files by modification time (newest first) for better performance
|
|
||||||
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
const allSessions = new Map();
|
const allSessions = new Map();
|
||||||
let processedCount = 0;
|
const allEntries = [];
|
||||||
|
const uuidToSessionMap = new Map();
|
||||||
|
|
||||||
// Process files in order of modification time
|
// Collect all sessions and entries from all files
|
||||||
for (const { file } of filesWithStats) {
|
for (const { file } of filesWithStats) {
|
||||||
const jsonlFile = path.join(projectDir, file);
|
const jsonlFile = path.join(projectDir, file);
|
||||||
const sessions = await parseJsonlSessions(jsonlFile);
|
const result = await parseJsonlSessions(jsonlFile);
|
||||||
|
|
||||||
// Merge sessions, avoiding duplicates by session ID
|
result.sessions.forEach(session => {
|
||||||
sessions.forEach(session => {
|
|
||||||
if (!allSessions.has(session.id)) {
|
if (!allSessions.has(session.id)) {
|
||||||
allSessions.set(session.id, session);
|
allSessions.set(session.id, session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
processedCount++;
|
allEntries.push(...result.entries);
|
||||||
|
|
||||||
// Early exit optimization: if we have enough sessions and processed recent files
|
// Early exit optimization for large projects
|
||||||
if (allSessions.size >= (limit + offset) * 2 && processedCount >= Math.min(3, filesWithStats.length)) {
|
if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to array and sort by last activity
|
// Build UUID-to-session mapping for timeline detection
|
||||||
const sortedSessions = Array.from(allSessions.values()).sort((a, b) =>
|
allEntries.forEach(entry => {
|
||||||
new Date(b.lastActivity) - new Date(a.lastActivity)
|
if (entry.uuid && entry.sessionId) {
|
||||||
);
|
uuidToSessionMap.set(entry.uuid, entry.sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const total = sortedSessions.length;
|
// Group sessions by first user message ID
|
||||||
const paginatedSessions = sortedSessions.slice(offset, offset + limit);
|
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
|
||||||
|
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
|
||||||
|
|
||||||
|
// Find the first user message for each session
|
||||||
|
allEntries.forEach(entry => {
|
||||||
|
if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
|
||||||
|
// This is a first user message in a session (parentUuid is null)
|
||||||
|
const firstUserMsgId = entry.uuid;
|
||||||
|
|
||||||
|
if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
|
||||||
|
sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
|
||||||
|
|
||||||
|
const session = allSessions.get(entry.sessionId);
|
||||||
|
if (session) {
|
||||||
|
if (!sessionGroups.has(firstUserMsgId)) {
|
||||||
|
sessionGroups.set(firstUserMsgId, {
|
||||||
|
latestSession: session,
|
||||||
|
allSessions: [session]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const group = sessionGroups.get(firstUserMsgId);
|
||||||
|
group.allSessions.push(session);
|
||||||
|
|
||||||
|
// Update latest session if this one is more recent
|
||||||
|
if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
|
||||||
|
group.latestSession = session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect all sessions that don't belong to any group (standalone sessions)
|
||||||
|
const groupedSessionIds = new Set();
|
||||||
|
sessionGroups.forEach(group => {
|
||||||
|
group.allSessions.forEach(session => groupedSessionIds.add(session.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const standaloneSessionsArray = Array.from(allSessions.values())
|
||||||
|
.filter(session => !groupedSessionIds.has(session.id));
|
||||||
|
|
||||||
|
// Combine grouped sessions (only show latest from each group) + standalone sessions
|
||||||
|
const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
|
||||||
|
const session = { ...group.latestSession };
|
||||||
|
// Add metadata about grouping
|
||||||
|
if (group.allSessions.length > 1) {
|
||||||
|
session.isGrouped = true;
|
||||||
|
session.groupSize = group.allSessions.length;
|
||||||
|
session.groupSessions = group.allSessions.map(s => s.id);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
|
||||||
|
.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||||
|
|
||||||
|
const total = visibleSessions.length;
|
||||||
|
const paginatedSessions = visibleSessions.slice(offset, offset + limit);
|
||||||
const hasMore = offset + limit < total;
|
const hasMore = offset + limit < total;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -591,6 +648,7 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
|||||||
|
|
||||||
async function parseJsonlSessions(filePath) {
|
async function parseJsonlSessions(filePath) {
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileStream = fsSync.createReadStream(filePath);
|
const fileStream = fsSync.createReadStream(filePath);
|
||||||
@@ -599,14 +657,11 @@ async function parseJsonlSessions(filePath) {
|
|||||||
crlfDelay: Infinity
|
crlfDelay: Infinity
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log(`[JSONL Parser] Reading file: ${filePath}`);
|
|
||||||
let lineCount = 0;
|
|
||||||
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
lineCount++;
|
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
|
entries.push(entry);
|
||||||
|
|
||||||
if (entry.sessionId) {
|
if (entry.sessionId) {
|
||||||
if (!sessions.has(entry.sessionId)) {
|
if (!sessions.has(entry.sessionId)) {
|
||||||
@@ -621,43 +676,37 @@ async function parseJsonlSessions(filePath) {
|
|||||||
|
|
||||||
const session = sessions.get(entry.sessionId);
|
const session = sessions.get(entry.sessionId);
|
||||||
|
|
||||||
// Update summary if this is a summary entry
|
// Update summary from summary entries or first user message
|
||||||
if (entry.type === 'summary' && entry.summary) {
|
if (entry.type === 'summary' && entry.summary) {
|
||||||
session.summary = entry.summary;
|
session.summary = entry.summary;
|
||||||
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
|
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
|
||||||
// Use first user message as summary if no summary entry exists
|
|
||||||
const content = entry.message.content;
|
const content = entry.message.content;
|
||||||
if (typeof content === 'string' && content.length > 0) {
|
if (typeof content === 'string' && content.length > 0 && !content.startsWith('<command-name>')) {
|
||||||
// Skip command messages that start with <command-name>
|
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
|
||||||
if (!content.startsWith('<command-name>')) {
|
|
||||||
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count messages instead of storing them all
|
session.messageCount++;
|
||||||
session.messageCount = (session.messageCount || 0) + 1;
|
|
||||||
|
|
||||||
// Update last activity
|
|
||||||
if (entry.timestamp) {
|
if (entry.timestamp) {
|
||||||
session.lastActivity = new Date(entry.timestamp);
|
session.lastActivity = new Date(entry.timestamp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.warn(`[JSONL Parser] Error parsing line ${lineCount}:`, parseError.message);
|
// Skip malformed lines silently
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`[JSONL Parser] Processed ${lineCount} lines, found ${sessions.size} sessions`);
|
return {
|
||||||
|
sessions: Array.from(sessions.values()),
|
||||||
|
entries: entries
|
||||||
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading JSONL file:', error);
|
console.error('Error reading JSONL file:', error);
|
||||||
|
return { sessions: [], entries: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert Map to Array and sort by last activity
|
|
||||||
return Array.from(sessions.values()).sort((a, b) =>
|
|
||||||
new Date(b.lastActivity) - new Date(a.lastActivity)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get messages for a specific session with pagination support
|
// Get messages for a specific session with pagination support
|
||||||
@@ -850,22 +899,16 @@ async function addProjectManually(projectPath, displayName = null) {
|
|||||||
// Generate project name (encode path for use as directory name)
|
// Generate project name (encode path for use as directory name)
|
||||||
const projectName = absolutePath.replace(/\//g, '-');
|
const projectName = absolutePath.replace(/\//g, '-');
|
||||||
|
|
||||||
// Check if project already exists in config or as a folder
|
// Check if project already exists in config
|
||||||
const config = await loadProjectConfig();
|
const config = await loadProjectConfig();
|
||||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.access(projectDir);
|
|
||||||
throw new Error(`Project already exists for path: ${absolutePath}`);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config[projectName]) {
|
if (config[projectName]) {
|
||||||
throw new Error(`Project already configured for path: ${absolutePath}`);
|
throw new Error(`Project already configured for path: ${absolutePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow adding projects even if the directory exists - this enables tracking
|
||||||
|
// existing Claude Code or Cursor projects in the UI
|
||||||
|
|
||||||
// Add to config as manually added project
|
// Add to config as manually added project
|
||||||
config[projectName] = {
|
config[projectName] = {
|
||||||
|
|||||||
106
src/App.jsx
106
src/App.jsx
@@ -23,7 +23,7 @@ import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from '
|
|||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import MainContent from './components/MainContent';
|
import MainContent from './components/MainContent';
|
||||||
import MobileNav from './components/MobileNav';
|
import MobileNav from './components/MobileNav';
|
||||||
import ToolsSettings from './components/ToolsSettings';
|
import Settings from './components/Settings';
|
||||||
import QuickSettingsPanel from './components/QuickSettingsPanel';
|
import QuickSettingsPanel from './components/QuickSettingsPanel';
|
||||||
|
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
@@ -33,6 +33,7 @@ import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
|||||||
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||||
|
import useLocalStorage from './hooks/useLocalStorage';
|
||||||
import { api, authenticatedFetch } from './utils/api';
|
import { api, authenticatedFetch } from './utils/api';
|
||||||
|
|
||||||
|
|
||||||
@@ -52,24 +53,12 @@ function AppContent() {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
const [showToolsSettings, setShowToolsSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||||||
const [autoExpandTools, setAutoExpandTools] = useState(() => {
|
const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false);
|
||||||
const saved = localStorage.getItem('autoExpandTools');
|
const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false);
|
||||||
return saved !== null ? JSON.parse(saved) : false;
|
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
|
||||||
});
|
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
|
||||||
const [showRawParameters, setShowRawParameters] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('showRawParameters');
|
|
||||||
return saved !== null ? JSON.parse(saved) : false;
|
|
||||||
});
|
|
||||||
const [autoScrollToBottom, setAutoScrollToBottom] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('autoScrollToBottom');
|
|
||||||
return saved !== null ? JSON.parse(saved) : true;
|
|
||||||
});
|
|
||||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('sendByCtrlEnter');
|
|
||||||
return saved !== null ? JSON.parse(saved) : false;
|
|
||||||
});
|
|
||||||
// Session Protection System: Track sessions with active conversations to prevent
|
// Session Protection System: Track sessions with active conversations to prevent
|
||||||
// automatic project updates from interrupting ongoing chats. When a user sends
|
// automatic project updates from interrupting ongoing chats. When a user sends
|
||||||
// a message, the session is marked as "active" and project updates are paused
|
// a message, the session is marked as "active" and project updates are paused
|
||||||
@@ -77,6 +66,37 @@ function AppContent() {
|
|||||||
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
|
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
|
||||||
|
|
||||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||||
|
|
||||||
|
// Detect if running as PWA
|
||||||
|
const [isPWA, setIsPWA] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if running in standalone mode (PWA)
|
||||||
|
const checkPWA = () => {
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
window.navigator.standalone ||
|
||||||
|
document.referrer.includes('android-app://');
|
||||||
|
setIsPWA(isStandalone);
|
||||||
|
|
||||||
|
// Add class to html and body for CSS targeting
|
||||||
|
if (isStandalone) {
|
||||||
|
document.documentElement.classList.add('pwa-mode');
|
||||||
|
document.body.classList.add('pwa-mode');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('pwa-mode');
|
||||||
|
document.body.classList.remove('pwa-mode');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPWA();
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -460,9 +480,10 @@ function AppContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<button
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
onClick={() => setShowVersionModal(false)}
|
onClick={() => setShowVersionModal(false)}
|
||||||
|
aria-label="Close version upgrade modal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
@@ -556,11 +577,13 @@ function AppContent() {
|
|||||||
onProjectDelete={handleProjectDelete}
|
onProjectDelete={handleProjectDelete}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
onRefresh={handleSidebarRefresh}
|
onRefresh={handleSidebarRefresh}
|
||||||
onShowSettings={() => setShowToolsSettings(true)}
|
onShowSettings={() => setShowSettings(true)}
|
||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
latestVersion={latestVersion}
|
latestVersion={latestVersion}
|
||||||
currentVersion={currentVersion}
|
currentVersion={currentVersion}
|
||||||
onShowVersionModal={() => setShowVersionModal(true)}
|
onShowVersionModal={() => setShowVersionModal(true)}
|
||||||
|
isPWA={isPWA}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +594,7 @@ function AppContent() {
|
|||||||
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
|
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
|
||||||
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
|
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
|
||||||
}`}>
|
}`}>
|
||||||
<div
|
<button
|
||||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -582,11 +605,13 @@ function AppContent() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
|
aria-label="Close sidebar"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border h-full transform transition-transform duration-150 ease-out ${
|
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
|
style={{ height: 'calc(100vh - 80px)' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -601,18 +626,20 @@ function AppContent() {
|
|||||||
onProjectDelete={handleProjectDelete}
|
onProjectDelete={handleProjectDelete}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
onRefresh={handleSidebarRefresh}
|
onRefresh={handleSidebarRefresh}
|
||||||
onShowSettings={() => setShowToolsSettings(true)}
|
onShowSettings={() => setShowSettings(true)}
|
||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
latestVersion={latestVersion}
|
latestVersion={latestVersion}
|
||||||
currentVersion={currentVersion}
|
currentVersion={currentVersion}
|
||||||
onShowVersionModal={() => setShowVersionModal(true)}
|
onShowVersionModal={() => setShowVersionModal(true)}
|
||||||
|
isPWA={isPWA}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content Area - Flexible */}
|
{/* Main Content Area - Flexible */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-16' : ''}`}>
|
||||||
<MainContent
|
<MainContent
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
@@ -622,6 +649,7 @@ function AppContent() {
|
|||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
isPWA={isPWA}
|
||||||
onMenuClick={() => setSidebarOpen(true)}
|
onMenuClick={() => setSidebarOpen(true)}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
onInputFocusChange={setIsInputFocused}
|
onInputFocusChange={setIsInputFocused}
|
||||||
@@ -629,7 +657,7 @@ function AppContent() {
|
|||||||
onSessionInactive={markSessionAsInactive}
|
onSessionInactive={markSessionAsInactive}
|
||||||
onReplaceTemporarySession={replaceTemporarySession}
|
onReplaceTemporarySession={replaceTemporarySession}
|
||||||
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
|
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
|
||||||
onShowSettings={() => setShowToolsSettings(true)}
|
onShowSettings={() => setShowSettings(true)}
|
||||||
autoExpandTools={autoExpandTools}
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
autoScrollToBottom={autoScrollToBottom}
|
autoScrollToBottom={autoScrollToBottom}
|
||||||
@@ -651,33 +679,21 @@ function AppContent() {
|
|||||||
isOpen={showQuickSettings}
|
isOpen={showQuickSettings}
|
||||||
onToggle={setShowQuickSettings}
|
onToggle={setShowQuickSettings}
|
||||||
autoExpandTools={autoExpandTools}
|
autoExpandTools={autoExpandTools}
|
||||||
onAutoExpandChange={(value) => {
|
onAutoExpandChange={setAutoExpandTools}
|
||||||
setAutoExpandTools(value);
|
|
||||||
localStorage.setItem('autoExpandTools', JSON.stringify(value));
|
|
||||||
}}
|
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
onShowRawParametersChange={(value) => {
|
onShowRawParametersChange={setShowRawParameters}
|
||||||
setShowRawParameters(value);
|
|
||||||
localStorage.setItem('showRawParameters', JSON.stringify(value));
|
|
||||||
}}
|
|
||||||
autoScrollToBottom={autoScrollToBottom}
|
autoScrollToBottom={autoScrollToBottom}
|
||||||
onAutoScrollChange={(value) => {
|
onAutoScrollChange={setAutoScrollToBottom}
|
||||||
setAutoScrollToBottom(value);
|
|
||||||
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
|
|
||||||
}}
|
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
onSendByCtrlEnterChange={(value) => {
|
onSendByCtrlEnterChange={setSendByCtrlEnter}
|
||||||
setSendByCtrlEnter(value);
|
|
||||||
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
|
|
||||||
}}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tools Settings Modal */}
|
{/* Settings Modal */}
|
||||||
<ToolsSettings
|
<Settings
|
||||||
isOpen={showToolsSettings}
|
isOpen={showSettings}
|
||||||
onClose={() => setShowToolsSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,11 @@ const safeLocalStorage = {
|
|||||||
|
|
||||||
// Memoized message component to prevent unnecessary re-renders
|
// Memoized message component to prevent unnecessary re-renders
|
||||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
prevMessage.type === 'assistant' &&
|
((prevMessage.type === 'assistant') ||
|
||||||
!prevMessage.isToolUse && !message.isToolUse;
|
(prevMessage.type === 'user') ||
|
||||||
|
(prevMessage.type === 'tool') ||
|
||||||
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = React.useRef(null);
|
const messageRef = React.useRef(null);
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -2017,6 +2019,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// When resuming a session, Claude CLI creates a new session instead of resuming.
|
// When resuming a session, Claude CLI creates a new session instead of resuming.
|
||||||
// We detect this by checking for system/init messages with session_id that differs
|
// We detect this by checking for system/init messages with session_id that differs
|
||||||
// from our current session. When found, we need to switch the user to the new session.
|
// from our current session. When found, we need to switch the user to the new session.
|
||||||
|
// This works exactly like new session detection - preserve messages during navigation.
|
||||||
if (latestMessage.data.type === 'system' &&
|
if (latestMessage.data.type === 'system' &&
|
||||||
latestMessage.data.subtype === 'init' &&
|
latestMessage.data.subtype === 'init' &&
|
||||||
latestMessage.data.session_id &&
|
latestMessage.data.session_id &&
|
||||||
@@ -2029,6 +2032,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark this as a system-initiated session change to preserve messages
|
// Mark this as a system-initiated session change to preserve messages
|
||||||
|
// This works exactly like new session init - messages stay visible during navigation
|
||||||
setIsSystemSessionChange(true);
|
setIsSystemSessionChange(true);
|
||||||
|
|
||||||
// Switch to the new session using React Router navigation
|
// Switch to the new session using React Router navigation
|
||||||
@@ -2743,7 +2747,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
// Get tools settings from localStorage based on provider
|
// Get tools settings from localStorage based on provider
|
||||||
const getToolsSettings = () => {
|
const getToolsSettings = () => {
|
||||||
try {
|
try {
|
||||||
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-tools-settings';
|
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings';
|
||||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
return JSON.parse(savedSettings);
|
return JSON.parse(savedSettings);
|
||||||
@@ -3215,7 +3219,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
{/* Input Area - Fixed Bottom */}
|
{/* Input Area - Fixed Bottom */}
|
||||||
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 ${
|
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 ${
|
||||||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
|
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-2 sm:pb-4 md:pb-6'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
41
src/components/DiffViewer.jsx
Normal file
41
src/components/DiffViewer.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
|
||||||
|
if (!diff) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
No diff available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDiffLine = (line, index) => {
|
||||||
|
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||||
|
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||||
|
const isHeader = line.startsWith('@@');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`font-mono text-xs p-2 ${
|
||||||
|
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' :
|
||||||
|
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||||
|
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||||
|
'text-gray-600 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="diff-viewer">
|
||||||
|
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiffViewer;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye } from 'lucide-react';
|
import { Input } from './ui/input';
|
||||||
|
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X } 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';
|
||||||
@@ -14,6 +15,8 @@ function FileTree({ selectedProject }) {
|
|||||||
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'); // 'simple', 'detailed', 'compact'
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
@@ -29,6 +32,51 @@ function FileTree({ selectedProject }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Filter files based on search query
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setFilteredFiles(files);
|
||||||
|
} else {
|
||||||
|
const filtered = filterFiles(files, searchQuery.toLowerCase());
|
||||||
|
setFilteredFiles(filtered);
|
||||||
|
|
||||||
|
// Auto-expand directories that contain matches
|
||||||
|
const expandMatches = (items) => {
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.type === 'directory' && item.children && item.children.length > 0) {
|
||||||
|
setExpandedDirs(prev => new Set(prev.add(item.path)));
|
||||||
|
expandMatches(item.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
expandMatches(filtered);
|
||||||
|
}
|
||||||
|
}, [files, searchQuery]);
|
||||||
|
|
||||||
|
// Recursively filter files and directories based on search query
|
||||||
|
const filterFiles = (items, query) => {
|
||||||
|
return items.reduce((filtered, item) => {
|
||||||
|
const matchesName = item.name.toLowerCase().includes(query);
|
||||||
|
let filteredChildren = [];
|
||||||
|
|
||||||
|
if (item.type === 'directory' && item.children) {
|
||||||
|
filteredChildren = filterFiles(item.children, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include item if:
|
||||||
|
// 1. It matches the search query, or
|
||||||
|
// 2. It's a directory with matching children
|
||||||
|
if (matchesName || filteredChildren.length > 0) {
|
||||||
|
filtered.push({
|
||||||
|
...item,
|
||||||
|
children: filteredChildren
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchFiles = async () => {
|
const fetchFiles = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -308,42 +356,67 @@ function FileTree({ selectedProject }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card">
|
<div className="h-full flex flex-col bg-card">
|
||||||
{/* View Mode Toggle */}
|
{/* Header with Search and View Mode Toggle */}
|
||||||
<div className="p-4 border-b border-border flex items-center justify-between">
|
<div className="p-4 border-b border-border space-y-3">
|
||||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-1">
|
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
<Button
|
||||||
size="sm"
|
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||||
className="h-8 w-8 p-0"
|
size="sm"
|
||||||
onClick={() => changeViewMode('simple')}
|
className="h-8 w-8 p-0"
|
||||||
title="Simple view"
|
onClick={() => changeViewMode('simple')}
|
||||||
>
|
title="Simple view"
|
||||||
<List className="w-4 h-4" />
|
>
|
||||||
</Button>
|
<List className="w-4 h-4" />
|
||||||
<Button
|
</Button>
|
||||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
<Button
|
||||||
size="sm"
|
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||||
className="h-8 w-8 p-0"
|
size="sm"
|
||||||
onClick={() => changeViewMode('compact')}
|
className="h-8 w-8 p-0"
|
||||||
title="Compact view"
|
onClick={() => changeViewMode('compact')}
|
||||||
>
|
title="Compact view"
|
||||||
<Eye className="w-4 h-4" />
|
>
|
||||||
</Button>
|
<Eye className="w-4 h-4" />
|
||||||
<Button
|
</Button>
|
||||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
<Button
|
||||||
size="sm"
|
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||||
className="h-8 w-8 p-0"
|
size="sm"
|
||||||
onClick={() => changeViewMode('detailed')}
|
className="h-8 w-8 p-0"
|
||||||
title="Detailed view"
|
onClick={() => changeViewMode('detailed')}
|
||||||
>
|
title="Detailed view"
|
||||||
<TableProperties className="w-4 h-4" />
|
>
|
||||||
</Button>
|
<TableProperties className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search files and folders..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8 pr-8 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Headers for Detailed View */}
|
{/* Column Headers for Detailed View */}
|
||||||
{viewMode === 'detailed' && files.length > 0 && (
|
{viewMode === 'detailed' && filteredFiles.length > 0 && (
|
||||||
<div className="px-4 pt-2 pb-1 border-b border-border">
|
<div className="px-4 pt-2 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-2 text-xs font-medium text-muted-foreground">
|
||||||
<div className="col-span-5">Name</div>
|
<div className="col-span-5">Name</div>
|
||||||
@@ -365,11 +438,21 @@ function FileTree({ selectedProject }) {
|
|||||||
Check if the project path is accessible
|
Check if the project path is accessible
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredFiles.length === 0 && searchQuery ? (
|
||||||
|
<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">
|
||||||
|
<Search className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-foreground mb-1">No matches found</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Try a different search term or clear the search
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
||||||
{viewMode === 'simple' && renderFileTree(files)}
|
{viewMode === 'simple' && renderFileTree(filteredFiles)}
|
||||||
{viewMode === 'compact' && renderCompactView(files)}
|
{viewMode === 'compact' && renderCompactView(filteredFiles)}
|
||||||
{viewMode === 'detailed' && renderDetailedView(files)}
|
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
|
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
|
||||||
import { MicButton } from './MicButton.jsx';
|
import { MicButton } from './MicButton.jsx';
|
||||||
import { authenticatedFetch } from '../utils/api';
|
import { authenticatedFetch } from '../utils/api';
|
||||||
|
import DiffViewer from './DiffViewer.jsx';
|
||||||
|
|
||||||
function GitPanel({ selectedProject, isMobile }) {
|
function GitPanel({ selectedProject, isMobile }) {
|
||||||
const [gitStatus, setGitStatus] = useState(null);
|
const [gitStatus, setGitStatus] = useState(null);
|
||||||
@@ -523,27 +524,6 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderDiffLine = (line, index) => {
|
|
||||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
|
||||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
|
||||||
const isHeader = line.startsWith('@@');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`font-mono text-xs ${
|
|
||||||
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' :
|
|
||||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
|
||||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
|
||||||
'text-gray-600 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusLabel = (status) => {
|
const getStatusLabel = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -590,7 +570,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
|
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
|
||||||
{commit.stats}
|
{commit.stats}
|
||||||
</div>
|
</div>
|
||||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
<DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -705,8 +685,8 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto p-2">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
{diff && <DiffViewer diff={diff} fileName={filePath} isMobile={isMobile} wrapText={wrapText} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import ChatInterface from './ChatInterface';
|
import ChatInterface from './ChatInterface';
|
||||||
import FileTree from './FileTree';
|
import FileTree from './FileTree';
|
||||||
import CodeEditor from './CodeEditor';
|
import CodeEditor from './CodeEditor';
|
||||||
import Shell from './Shell';
|
import StandaloneShell from './StandaloneShell';
|
||||||
import GitPanel from './GitPanel';
|
import GitPanel from './GitPanel';
|
||||||
import ErrorBoundary from './ErrorBoundary';
|
import ErrorBoundary from './ErrorBoundary';
|
||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
@@ -37,6 +37,7 @@ function MainContent({
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
messages,
|
messages,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
isPWA,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
isLoading,
|
isLoading,
|
||||||
onInputFocusChange,
|
onInputFocusChange,
|
||||||
@@ -152,10 +153,12 @@ function MainContent({
|
|||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header with menu button for mobile */}
|
{/* Header with menu button for mobile */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
<div
|
||||||
|
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="p-1.5 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"
|
className="p-1.5 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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
@@ -188,10 +191,12 @@ function MainContent({
|
|||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header with menu button for mobile */}
|
{/* Header with menu button for mobile */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
<div
|
||||||
|
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="p-1.5 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"
|
className="p-1.5 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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
@@ -224,7 +229,9 @@ function MainContent({
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header with tabs */}
|
{/* Header with tabs */}
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
<div
|
||||||
|
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
@@ -234,7 +241,7 @@ function MainContent({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onMenuClick();
|
onMenuClick();
|
||||||
}}
|
}}
|
||||||
className="p-2.5 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"
|
className="p-2.5 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"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
@@ -426,10 +433,11 @@ function MainContent({
|
|||||||
<FileTree selectedProject={selectedProject} />
|
<FileTree selectedProject={selectedProject} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
|
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
|
||||||
<Shell
|
<StandaloneShell
|
||||||
selectedProject={selectedProject}
|
project={selectedProject}
|
||||||
selectedSession={selectedSession}
|
session={selectedSession}
|
||||||
isActive={activeTab === 'shell'}
|
isActive={activeTab === 'shell'}
|
||||||
|
showHeader={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen } from 'lucide-react';
|
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn } from 'lucide-react';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||||
|
import StandaloneShell from './StandaloneShell';
|
||||||
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
|
import CursorLogo from './CursorLogo';
|
||||||
|
|
||||||
function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
function Settings({ isOpen, onClose, projects = [] }) {
|
||||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||||
const {
|
const {
|
||||||
tasksEnabled,
|
tasksEnabled,
|
||||||
@@ -59,6 +62,11 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
const [newCursorCommand, setNewCursorCommand] = useState('');
|
const [newCursorCommand, setNewCursorCommand] = useState('');
|
||||||
const [newCursorDisallowedCommand, setNewCursorDisallowedCommand] = useState('');
|
const [newCursorDisallowedCommand, setNewCursorDisallowedCommand] = useState('');
|
||||||
const [cursorMcpServers, setCursorMcpServers] = useState([]);
|
const [cursorMcpServers, setCursorMcpServers] = useState([]);
|
||||||
|
|
||||||
|
// Login modal states
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [loginProvider, setLoginProvider] = useState(''); // 'claude' or 'cursor'
|
||||||
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
// Common tool patterns for Claude
|
// Common tool patterns for Claude
|
||||||
const commonTools = [
|
const commonTools = [
|
||||||
'Bash(git log:*)',
|
'Bash(git log:*)',
|
||||||
@@ -325,7 +333,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
// Load Claude settings from localStorage
|
// Load Claude settings from localStorage
|
||||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
const savedSettings = localStorage.getItem('claude-settings');
|
||||||
|
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
const settings = JSON.parse(savedSettings);
|
const settings = JSON.parse(savedSettings);
|
||||||
@@ -371,6 +379,26 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Login handlers
|
||||||
|
const handleClaudeLogin = () => {
|
||||||
|
setLoginProvider('claude');
|
||||||
|
setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
|
||||||
|
setShowLoginModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCursorLogin = () => {
|
||||||
|
setLoginProvider('cursor');
|
||||||
|
setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
|
||||||
|
setShowLoginModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginComplete = (exitCode) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
// Login successful - could show a success message here
|
||||||
|
}
|
||||||
|
setShowLoginModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
const saveSettings = () => {
|
const saveSettings = () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveStatus(null);
|
setSaveStatus(null);
|
||||||
@@ -394,7 +422,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
localStorage.setItem('claude-tools-settings', JSON.stringify(claudeSettings));
|
localStorage.setItem('claude-settings', JSON.stringify(claudeSettings));
|
||||||
localStorage.setItem('cursor-tools-settings', JSON.stringify(cursorSettings));
|
localStorage.setItem('cursor-tools-settings', JSON.stringify(cursorSettings));
|
||||||
|
|
||||||
setSaveStatus('success');
|
setSaveStatus('success');
|
||||||
@@ -600,7 +628,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
<div className="bg-background border border-border md:rounded-lg shadow-xl w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col">
|
<div className="bg-background border border-border md:rounded-lg shadow-xl w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col">
|
||||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Settings className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
<SettingsIcon className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
||||||
<h2 className="text-lg md:text-xl font-semibold text-foreground">
|
<h2 className="text-lg md:text-xl font-semibold text-foreground">
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
@@ -739,7 +767,10 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Claude Tools
|
<div className="flex items-center gap-2">
|
||||||
|
<ClaudeLogo className="w-4 h-4" />
|
||||||
|
<span>Claude</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setToolsProvider('cursor')}
|
onClick={() => setToolsProvider('cursor')}
|
||||||
@@ -749,12 +780,15 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Cursor Tools
|
<div className="flex items-center gap-2">
|
||||||
|
<CursorLogo className="w-4 h-4" />
|
||||||
|
<span>Cursor</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claude Tools Content */}
|
{/* Claude Content */}
|
||||||
{toolsProvider === 'claude' && (
|
{toolsProvider === 'claude' && (
|
||||||
<div className="space-y-6 md:space-y-8">
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
|
||||||
@@ -786,6 +820,36 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Login */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LogIn className="w-5 h-5 text-blue-500" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
|
Authentication
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
Claude CLI Login
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Sign in to your Claude account to enable AI features
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleClaudeLogin}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4 mr-2" />
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Allowed Tools */}
|
{/* Allowed Tools */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1484,7 +1548,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cursor Tools Content */}
|
{/* Cursor Content */}
|
||||||
{toolsProvider === 'cursor' && (
|
{toolsProvider === 'cursor' && (
|
||||||
<div className="space-y-6 md:space-y-8">
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
|
||||||
@@ -1516,6 +1580,36 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cursor Login */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LogIn className="w-5 h-5 text-purple-500" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
|
Authentication
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-purple-900 dark:text-purple-100">
|
||||||
|
Cursor CLI Login
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
Sign in to your Cursor account to enable AI features
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCursorLogin}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4 mr-2" />
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Allowed Shell Commands */}
|
{/* Allowed Shell Commands */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1895,8 +1989,35 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Login Modal */}
|
||||||
|
{showLoginModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 max-md:items-stretch max-md:justify-stretch">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoginModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<StandaloneShell
|
||||||
|
project={selectedProject}
|
||||||
|
command={loginProvider === 'claude' ? 'claude /login' : 'cursor-agent login'}
|
||||||
|
onComplete={handleLoginComplete}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ToolsSettings;
|
export default Settings;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
@@ -54,7 +54,9 @@ function Sidebar({
|
|||||||
updateAvailable,
|
updateAvailable,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
onShowVersionModal
|
onShowVersionModal,
|
||||||
|
isPWA,
|
||||||
|
isMobile
|
||||||
}) {
|
}) {
|
||||||
const [expandedProjects, setExpandedProjects] = useState(new Set());
|
const [expandedProjects, setExpandedProjects] = useState(new Set());
|
||||||
const [editingProject, setEditingProject] = useState(null);
|
const [editingProject, setEditingProject] = useState(null);
|
||||||
@@ -72,6 +74,10 @@ function Sidebar({
|
|||||||
const [editingSessionName, setEditingSessionName] = useState('');
|
const [editingSessionName, setEditingSessionName] = useState('');
|
||||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
|
const [showPathDropdown, setShowPathDropdown] = useState(false);
|
||||||
|
const [pathList, setPathList] = useState([]);
|
||||||
|
const [filteredPaths, setFilteredPaths] = useState([]);
|
||||||
|
const [selectedPathIndex, setSelectedPathIndex] = useState(-1);
|
||||||
|
|
||||||
// TaskMaster context
|
// TaskMaster context
|
||||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||||
@@ -141,7 +147,7 @@ function Sidebar({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSortOrder = () => {
|
const loadSortOrder = () => {
|
||||||
try {
|
try {
|
||||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
const savedSettings = localStorage.getItem('claude-settings');
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
const settings = JSON.parse(savedSettings);
|
const settings = JSON.parse(savedSettings);
|
||||||
setProjectSortOrder(settings.projectSortOrder || 'name');
|
setProjectSortOrder(settings.projectSortOrder || 'name');
|
||||||
@@ -156,7 +162,7 @@ function Sidebar({
|
|||||||
|
|
||||||
// Listen for storage changes
|
// Listen for storage changes
|
||||||
const handleStorageChange = (e) => {
|
const handleStorageChange = (e) => {
|
||||||
if (e.key === 'claude-tools-settings') {
|
if (e.key === 'claude-settings') {
|
||||||
loadSortOrder();
|
loadSortOrder();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -176,6 +182,124 @@ function Sidebar({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load available paths for suggestions
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPaths = async () => {
|
||||||
|
try {
|
||||||
|
// Get recent paths from localStorage
|
||||||
|
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
||||||
|
|
||||||
|
// Load common/home directory paths
|
||||||
|
const response = await api.browseFilesystem();
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.suggestions) {
|
||||||
|
const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path }));
|
||||||
|
const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths];
|
||||||
|
setPathList(allPaths);
|
||||||
|
} else {
|
||||||
|
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading paths:', error);
|
||||||
|
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
||||||
|
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPaths();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing)
|
||||||
|
useEffect(() => {
|
||||||
|
const inputValue = newProjectPath.trim();
|
||||||
|
|
||||||
|
if (inputValue.length === 0) {
|
||||||
|
setShowPathDropdown(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show dropdown when user starts typing
|
||||||
|
setShowPathDropdown(true);
|
||||||
|
|
||||||
|
const updateSuggestions = async () => {
|
||||||
|
// First show filtered existing suggestions from pathList
|
||||||
|
const staticFiltered = pathList.filter(pathItem =>
|
||||||
|
pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||||
|
pathItem.path.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if input looks like a directory path for dynamic browsing
|
||||||
|
const isDirPath = inputValue.includes('/') && inputValue.length > 1;
|
||||||
|
|
||||||
|
if (isDirPath) {
|
||||||
|
try {
|
||||||
|
let dirToSearch;
|
||||||
|
|
||||||
|
// Determine which directory to search
|
||||||
|
if (inputValue.endsWith('/')) {
|
||||||
|
// User typed "/home/simos/" - search inside /home/simos
|
||||||
|
dirToSearch = inputValue.slice(0, -1);
|
||||||
|
} else {
|
||||||
|
// User typed "/home/simos/con" - search inside /home/simos for items starting with "con"
|
||||||
|
const lastSlashIndex = inputValue.lastIndexOf('/');
|
||||||
|
dirToSearch = inputValue.substring(0, lastSlashIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only search if we have a valid directory path (not root only)
|
||||||
|
if (dirToSearch && dirToSearch !== '') {
|
||||||
|
const response = await api.browseFilesystem(dirToSearch);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.suggestions) {
|
||||||
|
// Filter directories that match the current input
|
||||||
|
const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1);
|
||||||
|
const dynamicPaths = data.suggestions
|
||||||
|
.filter(suggestion => {
|
||||||
|
const dirName = suggestion.name;
|
||||||
|
return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true;
|
||||||
|
})
|
||||||
|
.map(s => ({ name: s.name, path: s.path }))
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
// Combine static and dynamic suggestions, prioritize dynamic
|
||||||
|
const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8);
|
||||||
|
setFilteredPaths(combined);
|
||||||
|
setSelectedPathIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Dynamic browsing failed:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to just static filtered suggestions
|
||||||
|
setFilteredPaths(staticFiltered.slice(0, 8));
|
||||||
|
setSelectedPathIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSuggestions();
|
||||||
|
}, [newProjectPath, pathList]);
|
||||||
|
|
||||||
|
// Select path from dropdown (ChatInterface pattern)
|
||||||
|
const selectPath = (pathItem) => {
|
||||||
|
setNewProjectPath(pathItem.path);
|
||||||
|
setShowPathDropdown(false);
|
||||||
|
setSelectedPathIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save path to recent paths
|
||||||
|
const saveToRecentPaths = (path) => {
|
||||||
|
try {
|
||||||
|
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
||||||
|
const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10);
|
||||||
|
localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving recent paths:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleProject = (projectName) => {
|
const toggleProject = (projectName) => {
|
||||||
const newExpanded = new Set(expandedProjects);
|
const newExpanded = new Set(expandedProjects);
|
||||||
if (newExpanded.has(projectName)) {
|
if (newExpanded.has(projectName)) {
|
||||||
@@ -347,8 +471,13 @@ function Sidebar({
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Save the path to recent paths before clearing
|
||||||
|
saveToRecentPaths(newProjectPath.trim());
|
||||||
|
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
setNewProjectPath('');
|
setNewProjectPath('');
|
||||||
|
setShowSuggestions(false);
|
||||||
|
|
||||||
// Refresh projects to show the new one
|
// Refresh projects to show the new one
|
||||||
if (window.refreshProjects) {
|
if (window.refreshProjects) {
|
||||||
@@ -371,6 +500,7 @@ function Sidebar({
|
|||||||
const cancelNewProject = () => {
|
const cancelNewProject = () => {
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
setNewProjectPath('');
|
setNewProjectPath('');
|
||||||
|
setShowSuggestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreSessions = async (project) => {
|
const loadMoreSessions = async (project) => {
|
||||||
@@ -434,7 +564,10 @@ function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card md:select-none">
|
<div
|
||||||
|
className="h-full flex flex-col bg-card md:select-none"
|
||||||
|
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="md:p-4 md:border-b md:border-border">
|
<div className="md:p-4 md:border-b md:border-border">
|
||||||
{/* Desktop Header */}
|
{/* Desktop Header */}
|
||||||
@@ -479,7 +612,10 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Header */}
|
{/* Mobile Header */}
|
||||||
<div className="md:hidden p-3 border-b border-border">
|
<div
|
||||||
|
className="md:hidden p-3 border-b border-border"
|
||||||
|
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||||
@@ -526,17 +662,96 @@ function Sidebar({
|
|||||||
<FolderPlus className="w-4 h-4" />
|
<FolderPlus className="w-4 h-4" />
|
||||||
Create New Project
|
Create New Project
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<div className="relative">
|
||||||
value={newProjectPath}
|
<Input
|
||||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
value={newProjectPath}
|
||||||
placeholder="/path/to/project or relative/path"
|
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||||
className="text-sm focus:ring-2 focus:ring-primary/20"
|
placeholder="/path/to/project or relative/path"
|
||||||
autoFocus
|
className="text-sm focus:ring-2 focus:ring-primary/20"
|
||||||
onKeyDown={(e) => {
|
autoFocus
|
||||||
if (e.key === 'Enter') createNewProject();
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') cancelNewProject();
|
// Handle path dropdown navigation (ChatInterface pattern)
|
||||||
}}
|
if (showPathDropdown && filteredPaths.length > 0) {
|
||||||
/>
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedPathIndex(prev =>
|
||||||
|
prev < filteredPaths.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedPathIndex(prev =>
|
||||||
|
prev > 0 ? prev - 1 : filteredPaths.length - 1
|
||||||
|
);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedPathIndex >= 0) {
|
||||||
|
selectPath(filteredPaths[selectedPathIndex]);
|
||||||
|
} else if (filteredPaths.length > 0) {
|
||||||
|
selectPath(filteredPaths[0]);
|
||||||
|
} else {
|
||||||
|
createNewProject();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowPathDropdown(false);
|
||||||
|
return;
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedPathIndex >= 0) {
|
||||||
|
selectPath(filteredPaths[selectedPathIndex]);
|
||||||
|
} else if (filteredPaths.length > 0) {
|
||||||
|
selectPath(filteredPaths[0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular input handling
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
createNewProject();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelNewProject();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Path dropdown (ChatInterface pattern) */}
|
||||||
|
{showPathDropdown && filteredPaths.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||||
|
{filteredPaths.map((pathItem, index) => (
|
||||||
|
<div
|
||||||
|
key={pathItem.path}
|
||||||
|
className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
|
||||||
|
index === selectedPathIndex
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectPath(pathItem);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{pathItem.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -559,8 +774,8 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Form - Simple Overlay */}
|
{/* Mobile Form - Simple Overlay */}
|
||||||
<div className="md:hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm">
|
<div className="md:hidden fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-end justify-center px-4 pb-24">
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
<div className="w-full max-w-sm bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
|
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
|
||||||
@@ -580,17 +795,92 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<div className="relative">
|
||||||
value={newProjectPath}
|
<Input
|
||||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
value={newProjectPath}
|
||||||
placeholder="/path/to/project or relative/path"
|
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||||
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
placeholder="/path/to/project or relative/path"
|
||||||
autoFocus
|
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
||||||
onKeyDown={(e) => {
|
autoFocus
|
||||||
if (e.key === 'Enter') createNewProject();
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') cancelNewProject();
|
// Handle path dropdown navigation (same as desktop)
|
||||||
}}
|
if (showPathDropdown && filteredPaths.length > 0) {
|
||||||
/>
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedPathIndex(prev =>
|
||||||
|
prev < filteredPaths.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedPathIndex(prev =>
|
||||||
|
prev > 0 ? prev - 1 : filteredPaths.length - 1
|
||||||
|
);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedPathIndex >= 0) {
|
||||||
|
selectPath(filteredPaths[selectedPathIndex]);
|
||||||
|
} else if (filteredPaths.length > 0) {
|
||||||
|
selectPath(filteredPaths[0]);
|
||||||
|
} else {
|
||||||
|
createNewProject();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowPathDropdown(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular input handling
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
createNewProject();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelNewProject();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: '16px', // Prevents zoom on iOS
|
||||||
|
WebkitAppearance: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile Path dropdown */}
|
||||||
|
{showPathDropdown && filteredPaths.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
|
||||||
|
{filteredPaths.map((pathItem, index) => (
|
||||||
|
<div
|
||||||
|
key={pathItem.path}
|
||||||
|
className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
|
||||||
|
index === selectedPathIndex
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectPath(pathItem);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{pathItem.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -1365,7 +1655,7 @@ function Sidebar({
|
|||||||
onClick={onShowSettings}
|
onClick={onShowSettings}
|
||||||
>
|
>
|
||||||
<Settings className="w-3 h-3" />
|
<Settings className="w-3 h-3" />
|
||||||
<span className="text-xs">Tools Settings</span>
|
<span className="text-xs">Settings</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
106
src/components/StandaloneShell.jsx
Normal file
106
src/components/StandaloneShell.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Shell from './Shell.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
|
||||||
|
* Provides a flexible API for both standalone and session-based usage.
|
||||||
|
*
|
||||||
|
* @param {Object} project - Project object with name, fullPath/path, displayName
|
||||||
|
* @param {Object} session - Session object (optional, for tab usage)
|
||||||
|
* @param {string} command - Initial command to run (optional)
|
||||||
|
* @param {boolean} isActive - Whether the shell is active (for tab usage, default: true)
|
||||||
|
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
|
||||||
|
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
|
||||||
|
* @param {function} onComplete - Callback when process completes (receives exitCode)
|
||||||
|
* @param {function} onClose - Callback for close button (optional)
|
||||||
|
* @param {string} title - Custom header title (optional)
|
||||||
|
* @param {string} className - Additional CSS classes
|
||||||
|
* @param {boolean} showHeader - Whether to show custom header (default: true)
|
||||||
|
* @param {boolean} compact - Use compact layout (default: false)
|
||||||
|
*/
|
||||||
|
function StandaloneShell({
|
||||||
|
project,
|
||||||
|
session = null,
|
||||||
|
command = null,
|
||||||
|
isActive = true,
|
||||||
|
isPlainShell = null, // Auto-detect: true if command provided, false if session provided
|
||||||
|
autoConnect = true,
|
||||||
|
onComplete = null,
|
||||||
|
onClose = null,
|
||||||
|
title = null,
|
||||||
|
className = "",
|
||||||
|
showHeader = true,
|
||||||
|
compact = false
|
||||||
|
}) {
|
||||||
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
|
|
||||||
|
// Auto-detect isPlainShell based on props
|
||||||
|
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
||||||
|
|
||||||
|
// Handle process completion
|
||||||
|
const handleProcessComplete = (exitCode) => {
|
||||||
|
setIsCompleted(true);
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete(exitCode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className={`h-full flex items-center justify-center ${className}`}>
|
||||||
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Project Selected</h3>
|
||||||
|
<p>A project is required to open a shell</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`h-full flex flex-col ${className}`}>
|
||||||
|
{/* Optional custom header */}
|
||||||
|
{showHeader && title && (
|
||||||
|
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
|
||||||
|
{isCompleted && (
|
||||||
|
<span className="text-xs text-green-400">(Completed)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shell component wrapper */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Shell
|
||||||
|
selectedProject={project}
|
||||||
|
selectedSession={session}
|
||||||
|
isActive={isActive}
|
||||||
|
initialCommand={command}
|
||||||
|
isPlainShell={shouldUsePlainShell}
|
||||||
|
onProcessComplete={handleProcessComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StandaloneShell;
|
||||||
41
src/hooks/useLocalStorage.jsx
Normal file
41
src/hooks/useLocalStorage.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to persist state in localStorage.
|
||||||
|
*
|
||||||
|
* @param {string} key The key to use for localStorage.
|
||||||
|
* @param {any} initialValue The initial value to use if nothing is in localStorage.
|
||||||
|
* @returns {[any, Function]} A tuple containing the stored value and a setter function.
|
||||||
|
*/
|
||||||
|
function useLocalStorage(key, initialValue) {
|
||||||
|
const [storedValue, setStoredValue] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : initialValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setValue = (value) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const valueToStore =
|
||||||
|
value instanceof Function ? value(storedValue) : value;
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [storedValue, setValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLocalStorage;
|
||||||
@@ -43,6 +43,22 @@
|
|||||||
--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;
|
||||||
|
|
||||||
|
/* Safe area CSS variables */
|
||||||
|
--safe-area-inset-top: env(safe-area-inset-top);
|
||||||
|
--safe-area-inset-right: env(safe-area-inset-right);
|
||||||
|
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||||
|
--safe-area-inset-left: env(safe-area-inset-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for older iOS versions */
|
||||||
|
@supports (padding-top: constant(safe-area-inset-top)) {
|
||||||
|
:root {
|
||||||
|
--safe-area-inset-top: constant(safe-area-inset-top);
|
||||||
|
--safe-area-inset-right: constant(safe-area-inset-right);
|
||||||
|
--safe-area-inset-bottom: constant(safe-area-inset-bottom);
|
||||||
|
--safe-area-inset-left: constant(safe-area-inset-left);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -82,12 +98,60 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Root element with safe area padding for PWA */
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply safe area padding in standalone mode */
|
||||||
|
@supports (padding-top: env(safe-area-inset-top)) {
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
#root {
|
||||||
|
padding-top: var(--safe-area-inset-top);
|
||||||
|
padding-left: var(--safe-area-inset-left);
|
||||||
|
padding-right: var(--safe-area-inset-right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PWA mode detected by JavaScript - more reliable */
|
||||||
|
html.pwa-mode,
|
||||||
|
body.pwa-mode {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgb(255 255 255); /* white - same as bg-white for safe area */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.pwa-mode #root {
|
||||||
|
padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust fixed inset positioning in PWA mode */
|
||||||
|
body.pwa-mode .fixed.inset-0 {
|
||||||
|
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||||
|
left: env(safe-area-inset-left);
|
||||||
|
right: env(safe-area-inset-right);
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode safe area background */
|
||||||
|
html.dark body.pwa-mode {
|
||||||
|
background-color: rgb(31 41 55); /* gray-800 - matches header color */
|
||||||
|
}
|
||||||
|
|
||||||
/* Global transition defaults */
|
/* Global transition defaults */
|
||||||
button,
|
button,
|
||||||
a,
|
a,
|
||||||
@@ -577,6 +641,25 @@
|
|||||||
padding-bottom: max(env(safe-area-inset-bottom), 12px);
|
padding-bottom: max(env(safe-area-inset-bottom), 12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PWA specific header adjustments for iOS */
|
||||||
|
.pwa-header-safe {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When PWA mode is detected by JavaScript */
|
||||||
|
body.pwa-mode .pwa-header-safe {
|
||||||
|
/* Reset padding since #root already handles safe area */
|
||||||
|
padding-top: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For mobile PWA, ensure proper header spacing */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
body.pwa-mode .pwa-header-safe {
|
||||||
|
padding-top: 4px !important;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.chat-input-mobile {
|
.chat-input-mobile {
|
||||||
padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
|
padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Browse filesystem for project suggestions
|
||||||
|
browseFilesystem: (dirPath = null) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (dirPath) params.append('path', dirPath);
|
||||||
|
|
||||||
|
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
||||||
|
},
|
||||||
|
|
||||||
// Generic GET method for any endpoint
|
// Generic GET method for any endpoint
|
||||||
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user