Merge branch 'main' into fix/registration-race-condition

This commit is contained in:
viper151
2025-07-23 14:20:07 +02:00
committed by GitHub
9 changed files with 442 additions and 93 deletions

View File

@@ -444,10 +444,25 @@ router.get('/remote-status', async (req, res) => {
trackingBranch = stdout.trim(); trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) { } catch (error) {
// No upstream branch configured // No upstream branch configured - but check if we have remotes
let hasRemote = false;
let remoteName = null;
try {
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length > 0) {
hasRemote = true;
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
}
} catch (remoteError) {
// No remotes configured
}
return res.json({ return res.json({
hasRemote: false, hasRemote,
hasUpstream: false,
branch, branch,
remoteName,
message: 'No remote tracking branch configured' message: 'No remote tracking branch configured'
}); });
} }
@@ -462,6 +477,7 @@ router.get('/remote-status', async (req, res) => {
res.json({ res.json({
hasRemote: true, hasRemote: true,
hasUpstream: true,
branch, branch,
remoteBranch: trackingBranch, remoteBranch: trackingBranch,
remoteName, remoteName,
@@ -653,6 +669,82 @@ router.post('/push', async (req, res) => {
} }
}); });
// Publish branch to remote (set upstream and push)
router.post('/publish', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch are required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch to verify it matches the requested branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const currentBranchName = currentBranch.trim();
if (currentBranchName !== branch) {
return res.status(400).json({
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
});
}
// Check if remote exists
let remoteName = 'origin';
try {
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length === 0) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
} catch (error) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
// Publish the branch (set upstream and push)
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Branch published successfully',
remoteName,
branch
});
} catch (error) {
console.error('Git publish error:', error);
// Enhanced error handling for common publish scenarios
let errorMessage = 'Publish failed';
let details = error.message;
if (error.message.includes('rejected')) {
errorMessage = 'Publish rejected';
details = 'The remote branch already exists and has different commits. Use push instead.';
} else if (error.message.includes('Could not resolve hostname')) {
errorMessage = 'Network error';
details = 'Unable to connect to remote repository. Check your internet connection.';
} else if (error.message.includes('Permission denied')) {
errorMessage = 'Authentication failed';
details = 'Permission denied. Check your credentials or SSH keys.';
} else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
errorMessage = 'Remote not configured';
details = 'Remote repository not properly configured. Check your remote URL.';
}
res.status(500).json({
error: errorMessage,
details: details
});
}
});
// Discard changes for a specific file // Discard changes for a specific file
router.post('/discard', async (req, res) => { router.post('/discard', async (req, res) => {
const { project, file } = req.body; const { project, file } = req.body;
@@ -692,4 +784,39 @@ router.post('/discard', async (req, res) => {
} }
}); });
// Delete untracked file
router.post('/delete-untracked', async (req, res) => {
const { project, file } = req.body;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Check if file is actually untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' });
}
const status = statusOutput.substring(0, 2);
if (status !== '??') {
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
}
// Delete the untracked file
await fs.unlink(path.join(projectPath, file));
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
} catch (error) {
console.error('Git delete untracked error:', error);
res.status(500).json({ error: error.message });
}
});
export default router; export default router;

View File

@@ -64,6 +64,10 @@ function AppContent() {
const saved = localStorage.getItem('autoScrollToBottom'); const saved = localStorage.getItem('autoScrollToBottom');
return saved !== null ? JSON.parse(saved) : true; 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
@@ -586,6 +590,7 @@ function AppContent() {
autoExpandTools={autoExpandTools} autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom} autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/> />
</div> </div>
@@ -617,6 +622,11 @@ function AppContent() {
setAutoScrollToBottom(value); setAutoScrollToBottom(value);
localStorage.setItem('autoScrollToBottom', JSON.stringify(value)); localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
}} }}
sendByCtrlEnter={sendByCtrlEnter}
onSendByCtrlEnterChange={(value) => {
setSendByCtrlEnter(value);
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
}}
isMobile={isMobile} isMobile={isMobile}
/> />
)} )}

View File

@@ -118,7 +118,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="w-full"> <div className="w-full">
{message.isToolUse ? ( {message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2"> <div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -423,41 +423,18 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
try { try {
const input = JSON.parse(message.toolInput); const input = JSON.parse(message.toolInput);
if (input.file_path) { if (input.file_path) {
// Extract filename
const filename = input.file_path.split('/').pop(); const filename = input.file_path.split('/').pop();
const pathParts = input.file_path.split('/');
const directoryPath = pathParts.slice(0, -1).join('/');
// Simple heuristic to show only relevant path parts
// Show the last 2-3 directory parts before the filename
const relevantParts = pathParts.slice(-4, -1); // Get up to 3 directories before filename
const relativePath = relevantParts.length > 0 ? relevantParts.join('/') + '/' : '';
return ( return (
<details className="mt-2" open={autoExpandTools}> <div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-1"> Read{' '}
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> onClick={() => onFileOpen && onFileOpen(input.file_path)}
</svg> className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> {filename}
</svg> </button>
<span className="text-gray-600 dark:text-gray-400 font-mono text-xs">{relativePath}</span> </div>
<span className="font-semibold text-blue-700 dark:text-blue-300 font-mono">{filename}</span>
</summary>
{showRawParameters && (
<div className="mt-3">
<details className="mt-2">
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
View raw parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
{message.toolInput}
</pre>
</details>
</div>
)}
</details>
); );
} }
} catch (e) { } catch (e) {
@@ -882,6 +859,61 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div> </div>
</div> </div>
</div> </div>
) : message.isToolUse && message.toolName === 'Read' ? (
// Simple Read tool indicator
(() => {
try {
const input = JSON.parse(message.toolInput);
if (input.file_path) {
const filename = input.file_path.split('/').pop();
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📖 Read{' '}
<button
onClick={() => onFileOpen && onFileOpen(input.file_path)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
{filename}
</button>
</div>
);
}
} catch (e) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📖 Read file
</div>
);
}
})()
) : message.isToolUse && message.toolName === 'TodoWrite' ? (
// Simple TodoWrite tool indicator with tasks
(() => {
try {
const input = JSON.parse(message.toolInput);
if (input.todos && Array.isArray(input.todos)) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2">
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
📝 Update todo list
</div>
<TodoList todos={input.todos} />
</div>
);
}
} catch (e) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📝 Update todo list
</div>
);
}
})()
) : message.isToolUse && message.toolName === 'TodoRead' ? (
// Simple TodoRead tool indicator
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📋 Read todo list
</div>
) : ( ) : (
<div className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-gray-700 dark:text-gray-300">
{message.type === 'assistant' ? ( {message.type === 'assistant' ? (
@@ -984,7 +1016,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID // - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
// //
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. // This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom }) { function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter }) {
const [input, setInput] = useState(() => { const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) { if (typeof window !== 'undefined' && selectedProject) {
return localStorage.getItem(`draft_input_${selectedProject.name}`) || ''; return localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
@@ -1400,19 +1432,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
toolResult: null // Will be updated when result comes in toolResult: null // Will be updated when result comes in
}]); }]);
} else if (part.type === 'text' && part.text?.trim()) { } else if (part.type === 'text' && part.text?.trim()) {
// Check for usage limit message and format it user-friendly
let content = part.text;
if (content.includes('Claude AI usage limit reached|')) {
const parts = content.split('|');
if (parts.length === 2) {
const timestamp = parseInt(parts[1]);
if (!isNaN(timestamp)) {
const resetTime = new Date(timestamp * 1000);
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
}
}
}
// Add regular text message // Add regular text message
setChatMessages(prev => [...prev, { setChatMessages(prev => [...prev, {
type: 'assistant', type: 'assistant',
content: part.text, content: content,
timestamp: new Date() timestamp: new Date()
}]); }]);
} }
} }
} else if (typeof messageData.content === 'string' && messageData.content.trim()) { } else if (typeof messageData.content === 'string' && messageData.content.trim()) {
// Check for usage limit message and format it user-friendly
let content = messageData.content;
if (content.includes('Claude AI usage limit reached|')) {
const parts = content.split('|');
if (parts.length === 2) {
const timestamp = parseInt(parts[1]);
if (!isNaN(timestamp)) {
const resetTime = new Date(timestamp * 1000);
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
}
}
}
// Add regular text message // Add regular text message
setChatMessages(prev => [...prev, { setChatMessages(prev => [...prev, {
type: 'assistant', type: 'assistant',
content: messageData.content, content: content,
timestamp: new Date() timestamp: new Date()
}]); }]);
} }
@@ -1966,14 +2024,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line // Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
if (e.key === 'Enter') { if (e.key === 'Enter') {
// If we're in composition, don't send message
if (e.nativeEvent.isComposing) {
return; // Let IME handle the Enter key
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) { if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
// Ctrl+Enter or Cmd+Enter: Send message // Ctrl+Enter or Cmd+Enter: Send message
e.preventDefault(); e.preventDefault();
handleSubmit(e); handleSubmit(e);
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { } else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
// Plain Enter: Also send message (keeping original behavior) // Plain Enter: Send message only if not in IME composition
e.preventDefault(); if (!sendByCtrlEnter) {
handleSubmit(e); e.preventDefault();
handleSubmit(e);
}
} }
// Shift+Enter: Allow default behavior (new line) // Shift+Enter: Allow default behavior (new line)
} }
@@ -2404,12 +2469,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div> </div>
{/* Hint text */} {/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block"> <div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
Press Enter to send Shift+Enter for new line Tab to change modes @ to reference files {sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
</div> </div>
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${ <div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0' isInputFocused ? 'opacity-100' : 'opacity-0'
}`}> }`}>
Enter to send Tab for modes @ for files {sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files"
: "Enter to send • Tab for modes • @ for files"}
</div> </div>
</form> </form>
</div> </div>

View File

@@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) {
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [isPulling, setIsPulling] = useState(false); const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false); const [isPushing, setIsPushing] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string } const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
const textareaRef = useRef(null); const textareaRef = useRef(null);
@@ -266,6 +267,34 @@ function GitPanel({ selectedProject, isMobile }) {
} }
}; };
const handlePublish = async () => {
setIsPublishing(true);
try {
const response = await authenticatedFetch('/api/git/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
branch: currentBranch
})
});
const data = await response.json();
if (data.success) {
// Refresh status after successful publish
fetchGitStatus();
fetchRemoteStatus();
} else {
console.error('Publish failed:', data.error);
// TODO: Show user-friendly error message
}
} catch (error) {
console.error('Error publishing branch:', error);
} finally {
setIsPublishing(false);
}
};
const discardChanges = async (filePath) => { const discardChanges = async (filePath) => {
try { try {
const response = await authenticatedFetch('/api/git/discard', { const response = await authenticatedFetch('/api/git/discard', {
@@ -294,6 +323,34 @@ function GitPanel({ selectedProject, isMobile }) {
} }
}; };
const deleteUntrackedFile = async (filePath) => {
try {
const response = await authenticatedFetch('/api/git/delete-untracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
file: filePath
})
});
const data = await response.json();
if (data.success) {
// Remove from selected files and refresh status
setSelectedFiles(prev => {
const newSet = new Set(prev);
newSet.delete(filePath);
return newSet;
});
fetchGitStatus();
} else {
console.error('Delete failed:', data.error);
}
} catch (error) {
console.error('Error deleting untracked file:', error);
}
};
const confirmAndExecute = async () => { const confirmAndExecute = async () => {
if (!confirmAction) return; if (!confirmAction) return;
@@ -305,6 +362,9 @@ function GitPanel({ selectedProject, isMobile }) {
case 'discard': case 'discard':
await discardChanges(file); await discardChanges(file);
break; break;
case 'delete':
await deleteUntrackedFile(file);
break;
case 'commit': case 'commit':
await handleCommit(); await handleCommit();
break; break;
@@ -314,6 +374,9 @@ function GitPanel({ selectedProject, isMobile }) {
case 'push': case 'push':
await handlePush(); await handlePush();
break; break;
case 'publish':
await handlePublish();
break;
} }
} catch (error) { } catch (error) {
console.error(`Error executing ${type}:`, error); console.error(`Error executing ${type}:`, error);
@@ -578,6 +641,23 @@ function GitPanel({ selectedProject, isMobile }) {
{isMobile && <span>Discard</span>} {isMobile && <span>Discard</span>}
</button> </button>
)} )}
{status === 'U' && (
<button
onClick={(e) => {
e.stopPropagation();
setConfirmAction({
type: 'delete',
file: filePath,
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
});
}}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
title="Delete untracked file"
>
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
{isMobile && <span>Delete</span>}
</button>
)}
<span <span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${ className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' : status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
@@ -716,51 +796,72 @@ function GitPanel({ selectedProject, isMobile }) {
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}> <div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
{/* Remote action buttons - smart logic based on ahead/behind status */} {/* Remote action buttons - smart logic based on ahead/behind status */}
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && ( {remoteStatus?.hasRemote && (
<> <>
{/* Pull button - show when behind (primary action) */} {/* Publish button - show when branch doesn't exist on remote */}
{remoteStatus.behind > 0 && ( {!remoteStatus?.hasUpstream && (
<button <button
onClick={() => setConfirmAction({ onClick={() => setConfirmAction({
type: 'pull', type: 'publish',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?` message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
})} })}
disabled={isPulling} disabled={isPublishing}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1" className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`} title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
> >
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} /> <Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span> <span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
</button> </button>
)} )}
{/* Push button - show when ahead (primary action when ahead only) */} {/* Show normal push/pull buttons only if branch has upstream */}
{remoteStatus.ahead > 0 && ( {remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
<button <>
onClick={() => setConfirmAction({ {/* Pull button - show when behind (primary action) */}
type: 'push', {remoteStatus.behind > 0 && (
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?` <button
})} onClick={() => setConfirmAction({
disabled={isPushing} type: 'pull',
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1" message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`} })}
> disabled={isPulling}
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} /> className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span> title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
</button> >
)} <Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
{/* Fetch button - show when ahead only or when diverged (secondary action) */} </button>
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( )}
<button
onClick={handleFetch} {/* Push button - show when ahead (primary action when ahead only) */}
disabled={isFetching} {remoteStatus.ahead > 0 && (
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1" <button
title={`Fetch from ${remoteStatus.remoteName}`} onClick={() => setConfirmAction({
> type: 'push',
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} /> message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span> })}
</button> disabled={isPushing}
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
</button>
)}
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
<button
onClick={handleFetch}
disabled={isFetching}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
title={`Fetch from ${remoteStatus.remoteName}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button>
)}
</>
)} )}
</> </>
)} )}
@@ -1120,16 +1221,18 @@ function GitPanel({ selectedProject, isMobile }) {
<div className="p-6"> <div className="p-6">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<div className={`p-2 rounded-full mr-3 ${ <div className={`p-2 rounded-full mr-3 ${
confirmAction.type === 'discard' ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900' (confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
}`}> }`}>
<AlertTriangle className={`w-5 h-5 ${ <AlertTriangle className={`w-5 h-5 ${
confirmAction.type === 'discard' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400' (confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
}`} /> }`} />
</div> </div>
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
{confirmAction.type === 'discard' ? 'Discard Changes' : {confirmAction.type === 'discard' ? 'Discard Changes' :
confirmAction.type === 'delete' ? 'Delete File' :
confirmAction.type === 'commit' ? 'Confirm Commit' : confirmAction.type === 'commit' ? 'Confirm Commit' :
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'} confirmAction.type === 'pull' ? 'Confirm Pull' :
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
</h3> </h3>
</div> </div>
@@ -1147,12 +1250,14 @@ function GitPanel({ selectedProject, isMobile }) {
<button <button
onClick={confirmAndExecute} onClick={confirmAndExecute}
className={`px-4 py-2 text-sm text-white rounded-md ${ className={`px-4 py-2 text-sm text-white rounded-md ${
confirmAction.type === 'discard' (confirmAction.type === 'discard' || confirmAction.type === 'delete')
? 'bg-red-600 hover:bg-red-700' ? 'bg-red-600 hover:bg-red-700'
: confirmAction.type === 'commit' : confirmAction.type === 'commit'
? 'bg-blue-600 hover:bg-blue-700' ? 'bg-blue-600 hover:bg-blue-700'
: confirmAction.type === 'pull' : confirmAction.type === 'pull'
? 'bg-green-600 hover:bg-green-700' ? 'bg-green-600 hover:bg-green-700'
: confirmAction.type === 'publish'
? 'bg-purple-600 hover:bg-purple-700'
: 'bg-orange-600 hover:bg-orange-700' : 'bg-orange-600 hover:bg-orange-700'
} flex items-center space-x-2`} } flex items-center space-x-2`}
> >
@@ -1161,6 +1266,11 @@ function GitPanel({ selectedProject, isMobile }) {
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
<span>Discard</span> <span>Discard</span>
</> </>
) : confirmAction.type === 'delete' ? (
<>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</>
) : confirmAction.type === 'commit' ? ( ) : confirmAction.type === 'commit' ? (
<> <>
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
@@ -1171,6 +1281,11 @@ function GitPanel({ selectedProject, isMobile }) {
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
<span>Pull</span> <span>Pull</span>
</> </>
) : confirmAction.type === 'publish' ? (
<>
<Upload className="w-4 h-4" />
<span>Publish</span>
</>
) : ( ) : (
<> <>
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo'; import { MessageSquare } from 'lucide-react';
const LoginForm = () => { const LoginForm = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -37,7 +37,9 @@ const LoginForm = () => {
{/* Logo and Title */} {/* Logo and Title */}
<div className="text-center"> <div className="text-center">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<ClaudeLogo size={64} /> <div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div> </div>
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1> <h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">

View File

@@ -39,7 +39,8 @@ function MainContent({
onShowSettings, // Show tools settings panel onShowSettings, // Show tools settings panel
autoExpandTools, // Auto-expand tool accordions autoExpandTools, // Auto-expand tool accordions
showRawParameters, // Show raw parameters in tool accordions showRawParameters, // Show raw parameters in tool accordions
autoScrollToBottom // Auto-scroll to bottom when new messages arrive autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
}) { }) {
const [editingFile, setEditingFile] = useState(null); const [editingFile, setEditingFile] = useState(null);
@@ -285,6 +286,7 @@ function MainContent({
autoExpandTools={autoExpandTools} autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom} autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/> />
</div> </div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}> <div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>

View File

@@ -2,13 +2,15 @@ import React from 'react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import SetupForm from './SetupForm'; import SetupForm from './SetupForm';
import LoginForm from './LoginForm'; import LoginForm from './LoginForm';
import ClaudeLogo from './ClaudeLogo'; import { MessageSquare } from 'lucide-react';
const LoadingScreen = () => ( const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4"> <div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center"> <div className="text-center">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<ClaudeLogo size={64} /> <div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div> </div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1> <h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">

View File

@@ -11,7 +11,8 @@ import {
Mic, Mic,
Brain, Brain,
Sparkles, Sparkles,
FileText FileText,
Languages
} from 'lucide-react'; } from 'lucide-react';
import DarkModeToggle from './DarkModeToggle'; import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
@@ -25,6 +26,8 @@ const QuickSettingsPanel = ({
onShowRawParametersChange, onShowRawParametersChange,
autoScrollToBottom, autoScrollToBottom,
onAutoScrollChange, onAutoScrollChange,
sendByCtrlEnter,
onSendByCtrlEnterChange,
isMobile isMobile
}) => { }) => {
const [localIsOpen, setLocalIsOpen] = useState(isOpen); const [localIsOpen, setLocalIsOpen] = useState(isOpen);
@@ -142,6 +145,27 @@ const QuickSettingsPanel = ({
</label> </label>
</div> </div>
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Send by Ctrl+Enter
</span>
<input
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */} {/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}> <div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4> <h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>

View File

@@ -374,9 +374,7 @@ function Sidebar({
try { try {
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0); const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
const response = await fetch( const response = await api.sessions(project.name, 5, currentSessionCount);
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
);
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();